diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..cd92a4c Binary files /dev/null and b/.DS_Store differ diff --git a/frontend/pshared/.gitignore b/frontend/pshared/.gitignore new file mode 100644 index 0000000..105c27d --- /dev/null +++ b/frontend/pshared/.gitignore @@ -0,0 +1,16 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +build/ +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock + +*.g.dart +lib/generated +untranslated.txt + +.flutter-plugins +.flutter-plugins-dependencies +devtools_options.yaml \ No newline at end of file diff --git a/frontend/pshared/CHANGELOG.md b/frontend/pshared/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/frontend/pshared/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/frontend/pshared/README.md b/frontend/pshared/README.md new file mode 100644 index 0000000..8b55e73 --- /dev/null +++ b/frontend/pshared/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/frontend/pshared/analysis_options.yaml b/frontend/pshared/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/frontend/pshared/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/pshared/assets/flag_of_catalonia.si b/frontend/pshared/assets/flag_of_catalonia.si new file mode 100644 index 0000000..3568e06 Binary files /dev/null and b/frontend/pshared/assets/flag_of_catalonia.si differ diff --git a/frontend/pshared/l10n.yaml b/frontend/pshared/l10n.yaml new file mode 100644 index 0000000..11495f3 --- /dev/null +++ b/frontend/pshared/l10n.yaml @@ -0,0 +1,7 @@ +arb-dir: lib/l10n +template-arb-file: ps_en.arb +output-dir: lib/generated/i18n +output-localization-file: ps_localizations.dart +output-class: PSLocalizations +synthetic-package: false +untranslated-messages-file: untranslated.txt diff --git a/frontend/pshared/lib/api/errors/authorization_failed.dart b/frontend/pshared/lib/api/errors/authorization_failed.dart new file mode 100644 index 0000000..7acc439 --- /dev/null +++ b/frontend/pshared/lib/api/errors/authorization_failed.dart @@ -0,0 +1,2 @@ +class AuthorizationFailed implements Exception { +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/errors/failed_to_read_image.dart b/frontend/pshared/lib/api/errors/failed_to_read_image.dart new file mode 100644 index 0000000..5b2b533 --- /dev/null +++ b/frontend/pshared/lib/api/errors/failed_to_read_image.dart @@ -0,0 +1,2 @@ +class ErrorFailedToReadImage implements Exception { +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/errors/unauthorized.dart b/frontend/pshared/lib/api/errors/unauthorized.dart new file mode 100644 index 0000000..220fc68 --- /dev/null +++ b/frontend/pshared/lib/api/errors/unauthorized.dart @@ -0,0 +1,2 @@ +class ErrorUnauthorized implements Exception { +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/errors/upload_failed.dart b/frontend/pshared/lib/api/errors/upload_failed.dart new file mode 100644 index 0000000..11c33dc --- /dev/null +++ b/frontend/pshared/lib/api/errors/upload_failed.dart @@ -0,0 +1,2 @@ +class ErrorUploadFailed implements Exception { +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/requests/change_password.dart b/frontend/pshared/lib/api/requests/change_password.dart new file mode 100644 index 0000000..5bdb21f --- /dev/null +++ b/frontend/pshared/lib/api/requests/change_password.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'change_password.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class ChangePassword { + @JsonKey(name: 'old') + final String oldPassword; + + @JsonKey(name: 'new') + final String newPassword; + + const ChangePassword({required this.oldPassword, required this.newPassword}); + + factory ChangePassword.fromJson(Map json) => _$ChangePasswordFromJson(json); + Map toJson() => _$ChangePasswordToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/change_role.dart b/frontend/pshared/lib/api/requests/change_role.dart new file mode 100644 index 0000000..adbeab9 --- /dev/null +++ b/frontend/pshared/lib/api/requests/change_role.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'change_role.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class ChangeRole { + final String accountRef; + final String newRoleDescriptionRef; + + const ChangeRole({ + required this.accountRef, + required this.newRoleDescriptionRef, + }); + + factory ChangeRole.fromJson(Map json) => _$ChangeRoleFromJson(json); + Map toJson() => _$ChangeRoleToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/file_upload.dart b/frontend/pshared/lib/api/requests/file_upload.dart new file mode 100644 index 0000000..2864b0a --- /dev/null +++ b/frontend/pshared/lib/api/requests/file_upload.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'file_upload.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class FileUpload { + + final String objRef; + + const FileUpload({ required this.objRef }); + + factory FileUpload.fromJson(Map json) => _$FileUploadFromJson(json); + Map toJson() => _$FileUploadToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/requests/login.dart b/frontend/pshared/lib/api/requests/login.dart new file mode 100644 index 0000000..532d132 --- /dev/null +++ b/frontend/pshared/lib/api/requests/login.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'login.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class LoginRequest { + final String login; + final String password; + final String locale; + final String clientId; + final String deviceId; + + const LoginRequest({ + required this.login, + required this.password, + required this.locale, + required this.clientId, + required this.deviceId, + }); + + factory LoginRequest.fromJson(Map json) => _$LoginRequestFromJson(json); + Map toJson() => _$LoginRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/permissions/change_policies.dart b/frontend/pshared/lib/api/requests/permissions/change_policies.dart new file mode 100644 index 0000000..b49f7cd --- /dev/null +++ b/frontend/pshared/lib/api/requests/permissions/change_policies.dart @@ -0,0 +1,38 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/data/policy.dart'; +import 'package:pshared/data/mapper/permissions/data/policy.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; + +part 'change_policies.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class PoliciesChangeRequest { + final List? add; + final List? remove; + + const PoliciesChangeRequest({ + this.add, + this.remove, + }); + + factory PoliciesChangeRequest.add({required List policies}) => PoliciesChangeRequest( + add: policies.map((policy) => policy.toDTO()).toList(), + ); + + factory PoliciesChangeRequest.remove({required List policies}) => PoliciesChangeRequest( + remove: policies.map((policy) => policy.toDTO()).toList(), + ); + + factory PoliciesChangeRequest.change({ + required List add, + required List remove, + }) => PoliciesChangeRequest( + add: add.map((policy) => policy.toDTO()).toList(), + remove: remove.map((policy) => policy.toDTO()).toList(), + ); + + factory PoliciesChangeRequest.fromJson(Map json) => _$PoliciesChangeRequestFromJson(json); + Map toJson() => _$PoliciesChangeRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/signup.dart b/frontend/pshared/lib/api/requests/signup.dart new file mode 100644 index 0000000..98a817a --- /dev/null +++ b/frontend/pshared/lib/api/requests/signup.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'signup.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class SignupRequest { + final String name; + final String login; + final String password; + final String locale; + final String organizationName; + final String organizationTimeZone; + + const SignupRequest({ + required this.name, + required this.login, + required this.password, + required this.locale, + required this.organizationName, + required this.organizationTimeZone, + }); + + factory SignupRequest.build({ + required String name, + required String login, + required String password, + required String locale, + required String organizationName, + required String organizationTimeZone, + }) => SignupRequest( + name: name, + login: login, + password: password, + locale: locale, + organizationName: organizationName, + organizationTimeZone: organizationTimeZone, + ); + + factory SignupRequest.fromJson(Map json) => _$SignupRequestFromJson(json); + Map toJson() => _$SignupRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/tokens/access_refresh.dart b/frontend/pshared/lib/api/requests/tokens/access_refresh.dart new file mode 100644 index 0000000..6f2b719 --- /dev/null +++ b/frontend/pshared/lib/api/requests/tokens/access_refresh.dart @@ -0,0 +1,4 @@ +import 'package:pshared/api/requests/tokens/refresh_rotate.dart'; + + +typedef AccessTokenRefreshRequest = RotateRefreshTokenRequest; \ No newline at end of file diff --git a/frontend/pshared/lib/api/requests/tokens/refresh_rotate.dart b/frontend/pshared/lib/api/requests/tokens/refresh_rotate.dart new file mode 100644 index 0000000..9d70659 --- /dev/null +++ b/frontend/pshared/lib/api/requests/tokens/refresh_rotate.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/requests/tokens/session_id.dart'; + +part 'refresh_rotate.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class RotateRefreshTokenRequest extends SessionID { + final String token; + + const RotateRefreshTokenRequest({ + required this.token, + required super.clientId, + required super.deviceId, + }); + + factory RotateRefreshTokenRequest.fromJson(Map json) => _$RotateRefreshTokenRequestFromJson(json); + @override + Map toJson() => _$RotateRefreshTokenRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/tokens/session_id.dart b/frontend/pshared/lib/api/requests/tokens/session_id.dart new file mode 100644 index 0000000..9040739 --- /dev/null +++ b/frontend/pshared/lib/api/requests/tokens/session_id.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'session_id.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class SessionID { + final String clientId; + final String deviceId; + + const SessionID({ + required this.clientId, + required this.deviceId, + }); + + factory SessionID.fromJson(Map json) => _$SessionIDFromJson(json); + Map toJson() => _$SessionIDToJson(this); +} diff --git a/frontend/pshared/lib/api/responses/account.dart b/frontend/pshared/lib/api/responses/account.dart new file mode 100644 index 0000000..8733871 --- /dev/null +++ b/frontend/pshared/lib/api/responses/account.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/account/account.dart'; + +part 'account.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class AccountResponse { + final AccountDTO account; + final TokenData accessToken; + + const AccountResponse({required this.accessToken, required this.account}); + + factory AccountResponse.fromJson(Map json) => _$AccountResponseFromJson(json); + Map toJson() => _$AccountResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/base.dart b/frontend/pshared/lib/api/responses/base.dart new file mode 100644 index 0000000..63c26f5 --- /dev/null +++ b/frontend/pshared/lib/api/responses/base.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/token.dart'; + +part 'base.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class BaseAuthorizedResponse { + final TokenData accessToken; + + const BaseAuthorizedResponse({required this.accessToken}); + + factory BaseAuthorizedResponse.fromJson(Map json) => _$BaseAuthorizedResponseFromJson(json); + Map toJson() => _$BaseAuthorizedResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/employees.dart b/frontend/pshared/lib/api/responses/employees.dart new file mode 100644 index 0000000..5d9fccf --- /dev/null +++ b/frontend/pshared/lib/api/responses/employees.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/account/account.dart'; + +part 'employees.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class EmployeesResponse { + final List accounts; + final TokenData accessToken; + + const EmployeesResponse({required this.accessToken, required this.accounts}); + + factory EmployeesResponse.fromJson(Map json) => _$EmployeesResponseFromJson(json); + Map toJson() => _$EmployeesResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/error/connectivity.dart b/frontend/pshared/lib/api/responses/error/connectivity.dart new file mode 100644 index 0000000..9795f4f --- /dev/null +++ b/frontend/pshared/lib/api/responses/error/connectivity.dart @@ -0,0 +1,13 @@ +class ConnectivityError implements Exception { + final int? code; + final String message; + + const ConnectivityError({this.code, required this.message}); + + @override + String toString() { + return code == null + ? 'Error response, message: $message)' + : 'Error response (code: $code, message: $message)'; + } +} diff --git a/frontend/pshared/lib/api/responses/error/server.dart b/frontend/pshared/lib/api/responses/error/server.dart new file mode 100644 index 0000000..992ae4d --- /dev/null +++ b/frontend/pshared/lib/api/responses/error/server.dart @@ -0,0 +1,43 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'server.g.dart'; + + +@JsonSerializable() +class ErrorResponse implements Exception { + final int code; + final String details; + final String source; + final String error; + + const ErrorResponse({ + required this.code, + required this.details, + required this.error, + required this.source, + }); + + @override + String toString() { + final buffer = StringBuffer('Error response (code: $code'); + + if (details.isNotEmpty) { + buffer.write(', details: $details'); + } + + if (error.isNotEmpty) { + buffer.write(', error: $error'); + } + + if (source.isNotEmpty) { + buffer.write(', source: $source'); + } + + buffer.write(')'); + + return buffer.toString(); + } + + factory ErrorResponse.fromJson(Map json) => _$ErrorResponseFromJson(json); + Map toJson() => _$ErrorResponseToJson(this); +} diff --git a/frontend/pshared/lib/api/responses/file_uploaded.dart b/frontend/pshared/lib/api/responses/file_uploaded.dart new file mode 100644 index 0000000..d178e10 --- /dev/null +++ b/frontend/pshared/lib/api/responses/file_uploaded.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'file_uploaded.g.dart'; + + +@JsonSerializable() +class FileUploaded { + + final String url; + + const FileUploaded({ required this.url }); + + factory FileUploaded.fromJson(Map json) => _$FileUploadedFromJson(json); + Map toJson() => _$FileUploadedToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/login.dart b/frontend/pshared/lib/api/responses/login.dart new file mode 100644 index 0000000..95a9ed2 --- /dev/null +++ b/frontend/pshared/lib/api/responses/login.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/account.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/account/account.dart'; + +part 'login.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class LoginResponse extends AccountResponse { + final TokenData refreshToken; + + const LoginResponse({required super.accessToken, required super.account, required this.refreshToken}); + + factory LoginResponse.fromJson(Map json) => _$LoginResponseFromJson(json); + @override + Map toJson() => _$LoginResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/message.dart b/frontend/pshared/lib/api/responses/message.dart new file mode 100644 index 0000000..97ba1d1 --- /dev/null +++ b/frontend/pshared/lib/api/responses/message.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/type.dart'; + +part 'message.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class HTTPMessage { + + @JsonKey(fromJson: MessageTypeExtension.fromJson, toJson: MessageTypeExtension.toJson) + final MessageType status; + final Map data; + + const HTTPMessage({ required this.data, required this.status }); + + factory HTTPMessage.fromJson(Map json) => _$HTTPMessageFromJson(json); + Map toJson() => _$HTTPMessageToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/organization.dart b/frontend/pshared/lib/api/responses/organization.dart new file mode 100644 index 0000000..ea05c9b --- /dev/null +++ b/frontend/pshared/lib/api/responses/organization.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/base.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/organization.dart'; + +part 'organization.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class OrganizationResponse extends BaseAuthorizedResponse { + final List organizations; + + const OrganizationResponse({required super.accessToken, required this.organizations}); + + factory OrganizationResponse.fromJson(Map json) => _$OrganizationResponseFromJson(json); + @override + Map toJson() => _$OrganizationResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/policies.dart b/frontend/pshared/lib/api/responses/policies.dart new file mode 100644 index 0000000..d097339 --- /dev/null +++ b/frontend/pshared/lib/api/responses/policies.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/base.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/permissions/data/permissions.dart'; +import 'package:pshared/data/dto/permissions/description/description.dart'; + +part 'policies.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class PoliciesResponse extends BaseAuthorizedResponse { + final PermissionsDescriptionDTO descriptions; + final PermissionsDataDTO permissions; + + const PoliciesResponse({required this.descriptions, required this.permissions, required super.accessToken}); + + factory PoliciesResponse.fromJson(Map json) => _$PoliciesResponseFromJson(json); + @override + Map toJson() => _$PoliciesResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/token.dart b/frontend/pshared/lib/api/responses/token.dart new file mode 100644 index 0000000..f485d46 --- /dev/null +++ b/frontend/pshared/lib/api/responses/token.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'token.g.dart'; + + +@JsonSerializable() +class TokenData { + final String token; + final DateTime expiration; + + const TokenData({required this.token, required this.expiration}); + + factory TokenData.fromJson(Map json) => _$TokenDataFromJson(json); + Map toJson() => _$TokenDataToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/responses/type.dart b/frontend/pshared/lib/api/responses/type.dart new file mode 100644 index 0000000..98673b8 --- /dev/null +++ b/frontend/pshared/lib/api/responses/type.dart @@ -0,0 +1,32 @@ + +enum MessageType { + success, + error, + request +} + +extension MessageTypeExtension on MessageType { + static String toJson(MessageType value) { + switch (value) { + case MessageType.success: + return 'success'; + case MessageType.error: + return 'error'; + case MessageType.request: + return 'request'; + } + } + + static MessageType fromJson(String json) { + switch (json) { + case 'success': + return MessageType.success; + case 'error': + return MessageType.error; + case 'request': + return MessageType.request; + default: + throw ArgumentError('Unknown HTTPMType string: $json'); + } + } +} diff --git a/frontend/pshared/lib/config/common.dart b/frontend/pshared/lib/config/common.dart new file mode 100644 index 0000000..b0f6de5 --- /dev/null +++ b/frontend/pshared/lib/config/common.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + + +class CommonConstants { + static String apiProto = const String.fromEnvironment('API_PROTO', defaultValue: 'http'); + static String apiHost = const String.fromEnvironment('API_HOST', defaultValue: 'localhost'); + // static String apiHost = 'localhost'; + // static String apiHost = '10.0.2.2'; + static String apiEndpoint = '/api/v1'; + static String amplitudeSecret = 'c3d75b3e2520d708440acbb16b923e79'; + static String amplitudeServerZone = 'EU'; + static Locale defaultLocale = const Locale('en'); + static String defaultCurrency = 'EUR'; + static int defaultDimensionLength = 500; + static String clientId = ''; + static String wsProto = 'ws'; + static String wsEndpoint = '/ws'; + static Color themeColor = Color.fromARGB(255, 80, 63, 224); + static String nilObjectRef = '000000000000000000000000'; + + // Public getters for shared properties + static String get serviceUrl => '$apiProto://$apiHost'; + static String get apiUrl => '$serviceUrl$apiEndpoint'; + static String get wsUrl => '$wsProto://$apiHost$apiEndpoint$wsEndpoint'; + static const String accessTokenStorageKey = 'access_token'; + static const String refreshTokenStorageKey = 'refresh_token'; + static const String currentOrgKey = 'current_org'; + static const String deviceIdStorageKey = 'device_id'; + + // Method to apply the configuration, called by platform-specific implementations + static void applyConfiguration(Map configJson) { + apiProto = configJson['apiProto'] ?? apiProto; + apiHost = configJson['apiHost'] ?? apiHost; + apiEndpoint = configJson['apiEndpoint'] ?? apiEndpoint; + amplitudeSecret = configJson['amplitudeSecret'] ?? amplitudeSecret; + amplitudeServerZone = configJson['amplitudeServerZone'] ?? amplitudeServerZone; + defaultLocale = Locale(configJson['defaultLocale'] ?? defaultLocale.languageCode); + defaultCurrency = configJson['defaultCurrency'] ?? defaultCurrency; + wsProto = configJson['wsProto'] ?? wsProto; + wsEndpoint = configJson['wsEndpoint'] ?? wsEndpoint; + defaultDimensionLength = configJson['defaultDimensionLength'] ?? defaultDimensionLength; + clientId = configJson['clientId'] ?? clientId; + if (configJson.containsKey('themeColor')) { + themeColor = Color(int.parse(configJson['themeColor'])); + } + } +} diff --git a/frontend/pshared/lib/config/constants.dart b/frontend/pshared/lib/config/constants.dart new file mode 100644 index 0000000..f88f154 --- /dev/null +++ b/frontend/pshared/lib/config/constants.dart @@ -0,0 +1,2 @@ +export 'mobile.dart' + if (dart.library.html) 'web.dart'; \ No newline at end of file diff --git a/frontend/pshared/lib/config/mobile.dart b/frontend/pshared/lib/config/mobile.dart new file mode 100644 index 0000000..ec5f56d --- /dev/null +++ b/frontend/pshared/lib/config/mobile.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:pshared/config/common.dart'; + + +class Constants extends CommonConstants { + static const String _clientIdMobile = 'com.profee.pay.mobile-3f9c3b76-2f89-4e9e-95a2-1a5b705b7a1d'; + + static Locale get defaultLocale => CommonConstants.defaultLocale; + static String get clientId => _clientIdMobile; + static String get accessTokenStorageKey => CommonConstants.accessTokenStorageKey; + static String get refreshTokenStorageKey => CommonConstants.refreshTokenStorageKey; + static String get currentOrgKey => CommonConstants.currentOrgKey; + static String get apiUrl => CommonConstants.apiUrl; + static String get serviceUrl => CommonConstants.serviceUrl; + static int get defaultDimensionLength => CommonConstants.defaultDimensionLength; + static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey; + static String get nilObjectRef => CommonConstants.nilObjectRef; + static Color get themeColor => CommonConstants.themeColor; + + static Future initialize() async { + var configFile = File('./config/config.json'); + if (await configFile.exists()) { + var configJson = jsonDecode(await configFile.readAsString()); + CommonConstants.applyConfiguration({ + ...configJson, + 'clientId': configJson['clientId'] ?? _clientIdMobile, + }); + } else { + CommonConstants.clientId = _clientIdMobile; + } + } +} diff --git a/frontend/pshared/lib/config/web.dart b/frontend/pshared/lib/config/web.dart new file mode 100644 index 0000000..cc49d85 --- /dev/null +++ b/frontend/pshared/lib/config/web.dart @@ -0,0 +1,75 @@ +import 'dart:ui'; + +import 'dart:js_interop'; + +import 'package:pshared/config/common.dart'; + + +/// Bind to the global JS `appConfig` (if it exists). +@JS() +external AppConfig? get appConfig; + +/// A staticInterop class for the JS object returned by `appConfig`. +@JS() +@staticInterop +class AppConfig {} + +/// Extension methods to expose each property on `AppConfig`. +extension AppConfigExtension on AppConfig { + external String? get apiProto; + external String? get apiHost; + external String? get apiEndpoint; + external String? get amplitudeSecret; + external String? get amplitudeServerZone; + external String? get defaultLocale; + external String? get wsProto; + external String? get wsEndpoint; + external int? get defaultDimensionLength; + external String? get themeColor; + external String? get clientId; +} + +class Constants extends CommonConstants { + static const String _clientIdWeb = 'com.profee.pay.web-4b6e8a0f-9b5c-4f57-b3a6-3c456e9bb2cd'; + + // Just re-expose these from CommonConstants: + static Locale get defaultLocale => CommonConstants.defaultLocale; + static String get clientId => _clientIdWeb; + static String get accessTokenStorageKey => CommonConstants.accessTokenStorageKey; + static String get refreshTokenStorageKey => CommonConstants.refreshTokenStorageKey; + static String get currentOrgKey => CommonConstants.currentOrgKey; + static String get apiUrl => CommonConstants.apiUrl; + static String get serviceUrl => CommonConstants.serviceUrl; + static int get defaultDimensionLength => CommonConstants.defaultDimensionLength; + static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey; + static String get nilObjectRef => CommonConstants.nilObjectRef; + static Color get themeColor => CommonConstants.themeColor; + + static Future initialize() async { + // Try to grab the JS `appConfig` if it exists: + final config = appConfig; + + if (config != null) { + // Build a Dart Map from the JS object’s properties: + final configJson = { + 'apiProto': config.apiProto, + 'apiHost': config.apiHost, + 'apiEndpoint': config.apiEndpoint, + 'amplitudeSecret': config.amplitudeSecret, + 'amplitudeServerZone': config.amplitudeServerZone, + 'defaultLocale': config.defaultLocale, + 'wsProto': config.wsProto, + 'wsEndpoint': config.wsEndpoint, + 'defaultDimensionLength': config.defaultDimensionLength, + 'themeColor': config.themeColor, + // If the JS side didn’t supply a clientId, fall back to our Dart constant + 'clientId': config.clientId ?? _clientIdWeb, + }; + + CommonConstants.applyConfiguration(configJson); + } else { + // No appConfig on JS side → just use the default Dart client ID. + CommonConstants.clientId = _clientIdWeb; + } + } +} diff --git a/frontend/pshared/lib/data/dto/account/account.dart b/frontend/pshared/lib/data/dto/account/account.dart new file mode 100644 index 0000000..b1a080b --- /dev/null +++ b/frontend/pshared/lib/data/dto/account/account.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/account/base.dart'; + +part 'account.g.dart'; + + +@JsonSerializable() +class AccountDTO extends AccountBaseDTO { + final String login; + + const AccountDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required super.name, + required super.avatarUrl, + required super.locale, + required this.login, + }); + + factory AccountDTO.fromJson(Map json) => _$AccountDTOFromJson(json); + @override + Map toJson() => _$AccountDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/account/base.dart b/frontend/pshared/lib/data/dto/account/base.dart new file mode 100644 index 0000000..0ae451d --- /dev/null +++ b/frontend/pshared/lib/data/dto/account/base.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/storable.dart'; + +part 'base.g.dart'; + + +@JsonSerializable() +class AccountBaseDTO extends StorableDTO { + final String name; + final String locale; + final String? avatarUrl; + + const AccountBaseDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required this.name, + required this.avatarUrl, + required this.locale, + }); + + factory AccountBaseDTO.fromJson(Map json) => _$AccountBaseDTOFromJson(json); + + @override + Map toJson() => _$AccountBaseDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/organization.dart b/frontend/pshared/lib/data/dto/organization.dart new file mode 100644 index 0000000..9e5cc76 --- /dev/null +++ b/frontend/pshared/lib/data/dto/organization.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/storable.dart'; + +part 'organization.g.dart'; + + +@JsonSerializable() +class OrganizationDTO extends StorableDTO { + final String timeZone; + final String? logoUrl; + + const OrganizationDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required this.timeZone, + this.logoUrl, + }); + + factory OrganizationDTO.fromJson(Map json) => _$OrganizationDTOFromJson(json); + @override + Map toJson() => _$OrganizationDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/organization/description.dart b/frontend/pshared/lib/data/dto/organization/description.dart new file mode 100644 index 0000000..2814f8b --- /dev/null +++ b/frontend/pshared/lib/data/dto/organization/description.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'description.g.dart'; + + +@JsonSerializable() +class OrganizationDescriptionDTO { + final String? logoUrl; + + const OrganizationDescriptionDTO({ + this.logoUrl, + }); + + factory OrganizationDescriptionDTO.fromJson(Map json) => _$OrganizationDescriptionDTOFromJson(json); + Map toJson() => _$OrganizationDescriptionDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/permissions/access.dart b/frontend/pshared/lib/data/dto/permissions/access.dart new file mode 100644 index 0000000..a69d970 --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/access.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/data/permissions.dart'; +import 'package:pshared/data/dto/permissions/description/description.dart'; + +part 'access.g.dart'; + + +@JsonSerializable() +class UserAccessDTO { + final PermissionsDescriptionDTO descriptions; + final PermissionsDataDTO permissions; + + const UserAccessDTO({ + required this.descriptions, + required this.permissions, + }); + + factory UserAccessDTO.fromJson(Map json) => _$UserAccessDTOFromJson(json); + Map toJson() => _$UserAccessDTOToJson(this); +} + diff --git a/frontend/pshared/lib/data/dto/permissions/action_effect.dart b/frontend/pshared/lib/data/dto/permissions/action_effect.dart new file mode 100644 index 0000000..fddfd44 --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/action_effect.dart @@ -0,0 +1,19 @@ +// data/action_effect_dto.dart +import 'package:json_annotation/json_annotation.dart'; + +part 'action_effect.g.dart'; + + +@JsonSerializable() +class ActionEffectDTO { + final String action; + final String effect; + + const ActionEffectDTO({ + required this.action, + required this.effect, + }); + + factory ActionEffectDTO.fromJson(Map json) => _$ActionEffectDTOFromJson(json); + Map toJson() => _$ActionEffectDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/permissions/data/permission.dart b/frontend/pshared/lib/data/dto/permissions/data/permission.dart new file mode 100644 index 0000000..2e4a3b7 --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/data/permission.dart @@ -0,0 +1,28 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/action_effect.dart'; + +part 'permission.g.dart'; + + +@JsonSerializable() +class PermissionDTO { + final String roleDescriptionRef; + final String organizationRef; + final String descriptionRef; + final String? objectRef; + final ActionEffectDTO effect; + final String accountRef; + + const PermissionDTO({ + required this.roleDescriptionRef, + required this.organizationRef, + required this.descriptionRef, + required this.objectRef, + required this.effect, + required this.accountRef, + }); + + factory PermissionDTO.fromJson(Map json) => _$PermissionDTOFromJson(json); + Map toJson() => _$PermissionDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/permissions/data/permissions.dart b/frontend/pshared/lib/data/dto/permissions/data/permissions.dart new file mode 100644 index 0000000..8759baf --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/data/permissions.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/data/permission.dart'; +import 'package:pshared/data/dto/permissions/data/policy.dart'; +import 'package:pshared/data/dto/permissions/data/role.dart'; + +part 'permissions.g.dart'; + + +@JsonSerializable() +class PermissionsDataDTO { + final List roles; + final List policies; + final List permissions; + + const PermissionsDataDTO({ + required this.roles, + required this.policies, + required this.permissions, + }); + + factory PermissionsDataDTO.fromJson(Map json) => _$PermissionsDataDTOFromJson(json); + Map toJson() => _$PermissionsDataDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/permissions/data/policy.dart b/frontend/pshared/lib/data/dto/permissions/data/policy.dart new file mode 100644 index 0000000..a3245de --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/data/policy.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/action_effect.dart'; + +part 'policy.g.dart'; + + +@JsonSerializable() +class PolicyDTO { + final String roleDescriptionRef; + final String organizationRef; + final String descriptionRef; + final String? objectRef; + final ActionEffectDTO effect; + + const PolicyDTO({ + required this.roleDescriptionRef, + required this.organizationRef, + required this.descriptionRef, + required this.objectRef, + required this.effect, + }); + + factory PolicyDTO.fromJson(Map json) => _$PolicyDTOFromJson(json); + Map toJson() => _$PolicyDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/permissions/data/role.dart b/frontend/pshared/lib/data/dto/permissions/data/role.dart new file mode 100644 index 0000000..d9926de --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/data/role.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'role.g.dart'; + + +@JsonSerializable() +class RoleDTO { + final String accountRef; + final String organizationRef; + final String descriptionRef; + + const RoleDTO({ + required this.accountRef, + required this.descriptionRef, + required this.organizationRef, + }); + + factory RoleDTO.fromJson(Map json) => _$RoleDTOFromJson(json); + Map toJson() => _$RoleDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/permissions/description/description.dart b/frontend/pshared/lib/data/dto/permissions/description/description.dart new file mode 100644 index 0000000..58e8cc8 --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/description/description.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/permissions/description/policy.dart'; +import 'package:pshared/data/dto/permissions/description/role.dart'; + +part 'description.g.dart'; + + +@JsonSerializable() +class PermissionsDescriptionDTO { + final List roles; + final List policies; + + const PermissionsDescriptionDTO({ + required this.roles, + required this.policies, + }); + + factory PermissionsDescriptionDTO.fromJson(Map json) => _$PermissionsDescriptionDTOFromJson(json); + Map toJson() => _$PermissionsDescriptionDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/permissions/description/policy.dart b/frontend/pshared/lib/data/dto/permissions/description/policy.dart new file mode 100644 index 0000000..04f4e43 --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/description/policy.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/storable.dart'; +import 'package:pshared/models/resources.dart'; + +part 'policy.g.dart'; + + +@JsonSerializable() +class PolicyDescriptionDTO extends StorableDTO { + final List? resourceTypes; + final String? organizationRef; + + const PolicyDescriptionDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required this.resourceTypes, + required this.organizationRef, + }); + + factory PolicyDescriptionDTO.fromJson(Map json) => _$PolicyDescriptionDTOFromJson(json); + + @override + Map toJson() => _$PolicyDescriptionDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/permissions/description/role.dart b/frontend/pshared/lib/data/dto/permissions/description/role.dart new file mode 100644 index 0000000..8b92caf --- /dev/null +++ b/frontend/pshared/lib/data/dto/permissions/description/role.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/storable.dart'; + +part 'role.g.dart'; + + +@JsonSerializable() +class RoleDescriptionDTO extends StorableDTO { + final String organizationRef; + + const RoleDescriptionDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required this.organizationRef, + }); + + factory RoleDescriptionDTO.fromJson(Map json) => _$RoleDescriptionDTOFromJson(json); + + @override + Map toJson() => _$RoleDescriptionDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/pfe/services.dart b/frontend/pshared/lib/data/dto/pfe/services.dart new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pshared/lib/data/dto/storable.dart b/frontend/pshared/lib/data/dto/storable.dart new file mode 100644 index 0000000..b189dff --- /dev/null +++ b/frontend/pshared/lib/data/dto/storable.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'storable.g.dart'; + + +@JsonSerializable() +class StorableDTO { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + + const StorableDTO({ + required this.id, + required this.createdAt, + required this.updatedAt, + }); + + factory StorableDTO.fromJson(Map json) => _$StorableDTOFromJson(json); + Map toJson() => _$StorableDTOToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/mapper/.DS_Store b/frontend/pshared/lib/data/mapper/.DS_Store new file mode 100644 index 0000000..226f367 Binary files /dev/null and b/frontend/pshared/lib/data/mapper/.DS_Store differ diff --git a/frontend/pshared/lib/data/mapper/account/account.dart b/frontend/pshared/lib/data/mapper/account/account.dart new file mode 100644 index 0000000..fd13dca --- /dev/null +++ b/frontend/pshared/lib/data/mapper/account/account.dart @@ -0,0 +1,26 @@ +import 'package:pshared/data/dto/account/account.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/models/storable.dart'; + + +extension AccountMapper on Account { + AccountDTO toDTO() => AccountDTO( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + name: name, + avatarUrl: avatarUrl, + locale: locale, + login: login, + ); +} + +extension AccountDTOMapper on AccountDTO { + Account toDomain() => Account( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt), + avatarUrl: avatarUrl, + locale: locale, + login: login, + name: name, + ); +} diff --git a/frontend/pshared/lib/data/mapper/account/base.dart b/frontend/pshared/lib/data/mapper/account/base.dart new file mode 100644 index 0000000..f0241ef --- /dev/null +++ b/frontend/pshared/lib/data/mapper/account/base.dart @@ -0,0 +1,24 @@ +import 'package:pshared/data/dto/account/base.dart'; +import 'package:pshared/models/account/base.dart'; +import 'package:pshared/models/storable.dart'; + + +extension AccountBaseMapper on AccountBase { + AccountBaseDTO toDTO() => AccountBaseDTO( + id: storable.id, + createdAt: storable.createdAt, + updatedAt: storable.updatedAt, + avatarUrl: avatarUrl, + name: name, + locale: locale, + ); +} + +extension AccountDTOMapper on AccountBaseDTO { + AccountBase toDomain() => AccountBase( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt), + avatarUrl: avatarUrl, + name: name, + locale: locale, + ); +} diff --git a/frontend/pshared/lib/data/mapper/icon.dart b/frontend/pshared/lib/data/mapper/icon.dart new file mode 100644 index 0000000..a9319d1 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/icon.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + + +final Map iconMapping = { + // General Project Management Icons + 'dashboard': Icons.dashboard, // Overview screen + 'project': Icons.work, // Represents a project + 'tasks': Icons.check_box, // Task list + 'calendar': Icons.calendar_today, // Calendar view + 'team': Icons.group, // Team collaboration + 'kanban': Icons.view_column, // Kanban board + 'timeline': Icons.timeline, // Project timeline + 'milestone': Icons.flag, // Milestones + 'priority': Icons.priority_high, // General priority indicator + 'settings': Icons.settings, // Settings or configurations + 'chat': Icons.chat, // Communication/chat + 'files': Icons.insert_drive_file, // File management + 'notes': Icons.note, // Notes or documentation + 'report': Icons.insert_chart, // Reporting and analytics + + // Priority Related Icons + 'to_do': Icons.assignment, // To do tasks + 'in_progress': Icons.autorenew, // Tasks in progress + 'complete': Icons.check_circle, // Completed tasks + 'archived': Icons.archive, // Archived tasks + + // Deadline Related Icons + 'deadline': Icons.timer, // Deadline indicator + 'reminder': Icons.alarm, // Deadline reminder + 'due_today': Icons.today, // Tasks due today + 'upcoming': Icons.event_available, // Upcoming deadlines + + // Additional + 'overdue': Icons.warning, // Overdue tasks or deadlines + 'budget': Icons.account_balance_wallet, // Budget and finance + 'resource': Icons.perm_contact_calendar, // Resource allocation + 'risk': Icons.warning_amber, // Risk management + 'feedback': Icons.feedback, // Feedback and reviews + 'timeline_edit': Icons.edit_calendar, // Edit project timeline + 'workflow': Icons.shuffle, // Workflow management + 'dependencies': Icons.link, // Task dependencies + 'progress': Icons.show_chart, // Project progress + 'schedule': Icons.schedule, // Scheduling + 'support': Icons.support, // Support/help + 'permissions': Icons.lock, // Access permissions + 'backup': Icons.backup, // Data backup + 'integration': Icons.extension, // Integrations + 'search': Icons.search, // Search functionality + 'announcement': Icons.announcement, // Announcements or updates + 'analytics': Icons.analytics, // Project analytics + 'assignment': Icons.assignment_turned_in, // Task assignments + 'discussions': Icons.forum, // Discussions/threads + 'timeline_view': Icons.view_timeline, // Detailed timeline view + 'board': Icons.dashboard_customize, // Custom project boards + 'approval': Icons.how_to_vote, // Approvals + 'review': Icons.rate_review, // Reviews and feedback + 'objective': Icons.golf_course, // Objectives/goals + 'settings_advanced': Icons.tune, // Advanced settings + 'time_tracking': Icons.timer_outlined, // Time tracking + 'checklist': Icons.checklist, // Checklists + 'sync': Icons.sync, // Syncing data + 'upload': Icons.cloud_upload, // Upload files + 'download': Icons.cloud_download, // Download files + 'share': Icons.share, // Sharing options + 'tag': Icons.label, // Tags/labels + 'notifications': Icons.notifications, // Notifications + 'user_roles': Icons.manage_accounts, // User roles and permissions + 'logout': Icons.logout, // Logout + 'automation': Icons.auto_awesome, // Automation + 'history': Icons.history, // Project history/logs + 'estimate': Icons.calculate, // Estimates and costing + 'quality': Icons.verified, // Quality assurance + 'strategy': Icons.lightbulb, // Strategy planning + 'feedback_form': Icons.comment, // Feedback forms + 'presentation': Icons.slideshow, // Project presentations +}; + + +class ProjectIcons { + static const IconData dashboard = Icons.dashboard; + static const IconData project = Icons.work; + static const IconData tasks = Icons.check_box; + static const IconData calendar = Icons.calendar_today; + static const IconData team = Icons.group; + static const IconData kanban = Icons.view_column; + static const IconData timeline = Icons.timeline; + static const IconData milestone = Icons.flag; + static const IconData priority = Icons.priority_high; + static const IconData settings = Icons.settings; + static const IconData chat = Icons.chat; + static const IconData files = Icons.insert_drive_file; + static const IconData notes = Icons.note; + static const IconData report = Icons.insert_chart; + static const IconData todo = Icons.assignment; + static const IconData inProgress = Icons.autorenew; + static const IconData complete = Icons.check_circle; + static const IconData archived = Icons.archive; + static const IconData deadline = Icons.timer; + static const IconData reminder = Icons.alarm; + static const IconData dueToday = Icons.today; + static const IconData upcoming = Icons.event_available; + static const IconData overdue = Icons.warning; + static const IconData budget = Icons.account_balance_wallet; + static const IconData resource = Icons.perm_contact_calendar; + static const IconData risk = Icons.warning_amber; + static const IconData feedback = Icons.feedback; + static const IconData timelineEdit = Icons.edit_calendar; + static const IconData workflow = Icons.shuffle; + static const IconData dependencies = Icons.link; + static const IconData progress = Icons.show_chart; + static const IconData schedule = Icons.schedule; + static const IconData support = Icons.support; + static const IconData permissions = Icons.lock; + static const IconData backup = Icons.backup; + static const IconData integration = Icons.extension; + static const IconData search = Icons.search; + static const IconData announcement = Icons.announcement; + static const IconData analytics = Icons.analytics; + static const IconData assignment = Icons.assignment_turned_in; + static const IconData discussions = Icons.forum; + static const IconData timelineView = Icons.view_timeline; + static const IconData board = Icons.dashboard_customize; + static const IconData approval = Icons.how_to_vote; + static const IconData review = Icons.rate_review; + static const IconData objective = Icons.golf_course; + static const IconData settingsAdvanced = Icons.tune; + static const IconData timeTracking = Icons.timer_outlined; + static const IconData checklist = Icons.checklist; + static const IconData sync = Icons.sync; + static const IconData upload = Icons.cloud_upload; + static const IconData download = Icons.cloud_download; + static const IconData share = Icons.share; + static const IconData tag = Icons.label; + static const IconData notifications = Icons.notifications; + static const IconData userRoles = Icons.manage_accounts; + static const IconData logout = Icons.logout; + static const IconData automation = Icons.auto_awesome; + static const IconData history = Icons.history; + static const IconData estimate = Icons.calculate; + static const IconData quality = Icons.verified; + static const IconData strategy = Icons.lightbulb; + static const IconData feedbackForm = Icons.comment; + static const IconData presentation = Icons.slideshow; +} + + +extension IconDataKeyExtension on IconData { + String get toIconKey { + return iconMapping.entries.firstWhere( + (entry) => entry.value == this, + orElse: () => throw Exception('IconData not found in mapping.'), + ).key; + } +} + +extension IconDataFromKeyExtension on String { + IconData get toIconData { + final iconData = iconMapping[this]; + if (iconData == null) { + throw Exception('No IconData found for key: $this'); + } + return iconData; + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/mapper/organization.dart b/frontend/pshared/lib/data/mapper/organization.dart new file mode 100644 index 0000000..41970d5 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/organization.dart @@ -0,0 +1,22 @@ +import 'package:pshared/data/dto/organization.dart'; +import 'package:pshared/models/organization/organization.dart'; +import 'package:pshared/models/storable.dart'; + + +extension OrganizationMapper on Organization { + OrganizationDTO toDTO() => OrganizationDTO( + id: storable.id, + createdAt: storable.createdAt, + updatedAt: storable.updatedAt, + timeZone: timeZone, + logoUrl: logoUrl, + ); +} + +extension OrganizationDTOMapper on OrganizationDTO { + Organization toDomain() => Organization( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt), + timeZone: timeZone, + logoUrl: logoUrl, + ); +} diff --git a/frontend/pshared/lib/data/mapper/organization/description.dart b/frontend/pshared/lib/data/mapper/organization/description.dart new file mode 100644 index 0000000..d80483f --- /dev/null +++ b/frontend/pshared/lib/data/mapper/organization/description.dart @@ -0,0 +1,15 @@ +import 'package:pshared/data/dto/organization/description.dart'; +import 'package:pshared/models/organization/description.dart'; + + +extension OrganizationDescriptionMapper on OrganizationDescription { + OrganizationDescriptionDTO toDTO() => OrganizationDescriptionDTO( + logoUrl: logoUrl, + ); +} + +extension AccountDescriptionDTOMapper on OrganizationDescriptionDTO { + OrganizationDescription toDomain() => OrganizationDescription( + logoUrl: logoUrl, + ); +} diff --git a/frontend/pshared/lib/data/mapper/permissions/action_effect.dart b/frontend/pshared/lib/data/mapper/permissions/action_effect.dart new file mode 100644 index 0000000..9fa205f --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/action_effect.dart @@ -0,0 +1,23 @@ +import 'package:pshared/data/dto/permissions/action_effect.dart'; +import 'package:pshared/models/permissions/action_effect.dart'; +import 'package:pshared/models/permissions/action.dart'; +import 'package:pshared/models/permissions/effect.dart'; + + +extension ActionEffectMapper on ActionEffect { + ActionEffectDTO toDTO() { + return ActionEffectDTO( + action: action.toShortString(), + effect: effect.toShortString(), + ); + } +} + +extension ActionEffectDTOMapper on ActionEffectDTO { + ActionEffect toDomain() { + return ActionEffect( + action: ActionExtension.fromString(action), + effect: EffectExtension.fromString(effect), + ); + } +} diff --git a/frontend/pshared/lib/data/mapper/permissions/data/permission.dart b/frontend/pshared/lib/data/mapper/permissions/data/permission.dart new file mode 100644 index 0000000..82ebca6 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/data/permission.dart @@ -0,0 +1,33 @@ +import 'package:pshared/data/dto/permissions/data/permission.dart'; +import 'package:pshared/data/mapper/permissions/action_effect.dart'; +import 'package:pshared/models/permissions/data/permission.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; + + +extension PermissionMapper on Permission { + PermissionDTO toDTO() { + return PermissionDTO( + roleDescriptionRef: policy.roleDescriptionRef, + organizationRef: policy.organizationRef, + descriptionRef: policy.descriptionRef, + objectRef: policy.objectRef, + effect: policy.effect.toDTO(), + accountRef: accountRef, + ); + } +} + +extension PermissionDTOMapper on PermissionDTO { + Permission toDomain() { + return Permission( + policy: Policy( + roleDescriptionRef: roleDescriptionRef, + organizationRef: organizationRef, + descriptionRef: descriptionRef, + objectRef: objectRef, + effect: effect.toDomain(), + ), + accountRef: accountRef, + ); + } +} diff --git a/frontend/pshared/lib/data/mapper/permissions/data/permissions.dart b/frontend/pshared/lib/data/mapper/permissions/data/permissions.dart new file mode 100644 index 0000000..4d71717 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/data/permissions.dart @@ -0,0 +1,26 @@ +import 'package:pshared/data/dto/permissions/data/permissions.dart'; +import 'package:pshared/data/mapper/permissions/data/permission.dart'; +import 'package:pshared/data/mapper/permissions/data/policy.dart'; +import 'package:pshared/data/mapper/permissions/data/role.dart'; +import 'package:pshared/models/permissions/data/permissions.dart'; + + +extension PermissionsDataMapper on PermissionsData { + PermissionsDataDTO toDTO() { + return PermissionsDataDTO( + roles: roles.map((role) => role.toDTO()).toList(), + policies: policies.map((policy) => policy.toDTO()).toList(), + permissions: permissions.map((permission) => permission.toDTO()).toList(), + ); + } +} + +extension PermissionsDataDTOMapper on PermissionsDataDTO { + PermissionsData toDomain() { + return PermissionsData( + roles: roles.map((role) => role.toDomain()).toList(), + policies: policies.map((policy) => policy.toDomain()).toList(), + permissions: permissions.map((permission) => permission.toDomain()).toList(), + ); + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/mapper/permissions/data/policy.dart b/frontend/pshared/lib/data/mapper/permissions/data/policy.dart new file mode 100644 index 0000000..70de889 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/data/policy.dart @@ -0,0 +1,28 @@ +import 'package:pshared/data/dto/permissions/data/policy.dart'; +import 'package:pshared/data/mapper/permissions/action_effect.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; + + +extension PolicyMapper on Policy { + PolicyDTO toDTO() { + return PolicyDTO( + roleDescriptionRef: roleDescriptionRef, + organizationRef: organizationRef, + descriptionRef: descriptionRef, + objectRef: objectRef, + effect: effect.toDTO(), + ); + } +} + +extension PolicyDTOMapper on PolicyDTO { + Policy toDomain() { + return Policy( + roleDescriptionRef: roleDescriptionRef, + organizationRef: organizationRef, + descriptionRef: descriptionRef, + objectRef: objectRef, + effect: effect.toDomain(), + ); + } +} diff --git a/frontend/pshared/lib/data/mapper/permissions/data/role.dart b/frontend/pshared/lib/data/mapper/permissions/data/role.dart new file mode 100644 index 0000000..de959da --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/data/role.dart @@ -0,0 +1,25 @@ +import 'package:pshared/data/dto/permissions/data/role.dart'; +import 'package:pshared/models/permissions/data/role.dart'; + + +extension RoleMapper on Role { + /// Converts a `Role` domain model to a `RoleDTO`. + RoleDTO toDTO() { + return RoleDTO( + accountRef: accountRef, + descriptionRef: descriptionRef, + organizationRef: organizationRef, + ); + } +} + +extension RoleDTOMapper on RoleDTO { + /// Converts a `RoleDTO` to a `Role` domain model. + Role toDomain() { + return Role( + accountRef: accountRef, + descriptionRef: descriptionRef, + organizationRef: organizationRef, + ); + } +} diff --git a/frontend/pshared/lib/data/mapper/permissions/descriptions/description.dart b/frontend/pshared/lib/data/mapper/permissions/descriptions/description.dart new file mode 100644 index 0000000..4d8ac22 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/descriptions/description.dart @@ -0,0 +1,23 @@ +import 'package:pshared/data/dto/permissions/description/description.dart'; +import 'package:pshared/data/mapper/permissions/descriptions/policy.dart'; +import 'package:pshared/data/mapper/permissions/descriptions/role.dart'; +import 'package:pshared/models/permissions/descriptions/permissions.dart'; + + +extension PermissionsDescriptionMapper on PermissionsDescription { + PermissionsDescriptionDTO toDTO() { + return PermissionsDescriptionDTO( + roles: roles.map((role) => role.toDTO()).toList(), + policies: policies.map((policy) => policy.toDTO()).toList(), + ); + } +} + +extension PermissionsDescriptionDTOMapper on PermissionsDescriptionDTO { + PermissionsDescription toDomain() { + return PermissionsDescription( + roles: roles.map((role) => role.toDomain()).toList(), + policies: policies.map((policy) => policy.toDomain()).toList(), + ); + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/mapper/permissions/descriptions/policy.dart b/frontend/pshared/lib/data/mapper/permissions/descriptions/policy.dart new file mode 100644 index 0000000..e444a9d --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/descriptions/policy.dart @@ -0,0 +1,22 @@ +import 'package:pshared/data/dto/permissions/description/policy.dart'; +import 'package:pshared/models/permissions/descriptions/policy.dart'; +import 'package:pshared/models/storable.dart'; + + +extension PolicyDescriptionMapper on PolicyDescription { + PolicyDescriptionDTO toDTO() => PolicyDescriptionDTO( + id: storable.id, + createdAt: storable.createdAt, + updatedAt: storable.updatedAt, + resourceTypes: resourceTypes, + organizationRef: organizationRef, + ); +} + +extension PolicyDescriptionDTOMapper on PolicyDescriptionDTO { + PolicyDescription toDomain() => PolicyDescription( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: createdAt), + resourceTypes: resourceTypes, + organizationRef: organizationRef, + ); +} diff --git a/frontend/pshared/lib/data/mapper/permissions/descriptions/role.dart b/frontend/pshared/lib/data/mapper/permissions/descriptions/role.dart new file mode 100644 index 0000000..d0596cb --- /dev/null +++ b/frontend/pshared/lib/data/mapper/permissions/descriptions/role.dart @@ -0,0 +1,20 @@ +import 'package:pshared/data/dto/permissions/description/role.dart'; +import 'package:pshared/models/permissions/descriptions/role.dart'; +import 'package:pshared/models/storable.dart'; + + +extension RoleDescriptionMapper on RoleDescription { + RoleDescriptionDTO toDTO() => RoleDescriptionDTO( + id: storable.id, + createdAt: storable.createdAt, + updatedAt: storable.updatedAt, + organizationRef: organizationRef, + ); +} + +extension RoleDescriptionDTOMapper on RoleDescriptionDTO { + RoleDescription toDomain() => RoleDescription( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt), + organizationRef: organizationRef, + ); +} diff --git a/frontend/pshared/lib/data/mapper/storable.dart b/frontend/pshared/lib/data/mapper/storable.dart new file mode 100644 index 0000000..8990947 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/storable.dart @@ -0,0 +1,11 @@ +import 'package:pshared/data/dto/storable.dart'; +import 'package:pshared/models/storable.dart'; + + +extension StorableMapper on Storable { + StorableDTO toDTO() => StorableDTO(id: id, createdAt: createdAt, updatedAt: updatedAt); +} + +extension StorableDTOMapper on StorableDTO { + Storable toDomain() => newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt); +} diff --git a/frontend/pshared/lib/l10n/ps_en.arb b/frontend/pshared/lib/l10n/ps_en.arb new file mode 100644 index 0000000..53fa531 --- /dev/null +++ b/frontend/pshared/lib/l10n/ps_en.arb @@ -0,0 +1,23 @@ +{ + "@@locale": "en", + + "statusReady": "Ready", + "statusRegistered": "Registered", + "statusNotRegistered": "Not Registered", + "typeInternal": "Internal", + "typeExternal": "External", + "operationStatusProcessing": "Processing", + "@operationStatusProcessing": { + "description": "Label for the “processing” operation status" + }, + + "operationStatusSuccess": "Success", + "@operationStatusSuccess": { + "description": "Label for the “success” operation status" + }, + + "operationStatusError": "Error", + "@operationStatusError": { + "description": "Label for the “error” operation status" + } +} diff --git a/frontend/pshared/lib/models/account/account.dart b/frontend/pshared/lib/models/account/account.dart new file mode 100644 index 0000000..2d41b0a --- /dev/null +++ b/frontend/pshared/lib/models/account/account.dart @@ -0,0 +1,36 @@ +import 'package:pshared/models/account/base.dart'; + + +class Account extends AccountBase { + final String login; + + const Account({ + required super.storable, + required super.avatarUrl, + required this.login, + required super.locale, + required super.name, + }); + + factory Account.fromBase(AccountBase accountBase, String login) => Account( + storable: accountBase.storable, + avatarUrl: accountBase.avatarUrl, + locale: accountBase.locale, + name: accountBase.name, + login: login, + ); + + @override + Account copyWith({ + String? Function()? avatarUrl, + String? name, + String? locale, + }) { + final updatedBase = super.copyWith( + avatarUrl: avatarUrl, + name: name, + locale: locale, + ); + return Account.fromBase(updatedBase, login); + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/account/base.dart b/frontend/pshared/lib/models/account/base.dart new file mode 100644 index 0000000..5fdd112 --- /dev/null +++ b/frontend/pshared/lib/models/account/base.dart @@ -0,0 +1,35 @@ +import 'package:pshared/models/storable.dart'; + + +class AccountBase implements Storable { + final Storable storable; + + @override + String get id => storable.id; + @override + DateTime get createdAt => storable.createdAt; + @override + DateTime get updatedAt => storable.updatedAt; + + final String? avatarUrl; + final String name; + final String locale; + + const AccountBase({ + required this.storable, + required this.name, + required this.locale, + required this.avatarUrl, + }); + + AccountBase copyWith({ + String? Function()? avatarUrl, + String? name, + String? locale, + }) => AccountBase( + storable: storable, + avatarUrl: avatarUrl != null ? avatarUrl() : this.avatarUrl, + locale: locale ?? this.locale, + name: name ?? this.name, + ); +} diff --git a/frontend/pshared/lib/models/organization/description.dart b/frontend/pshared/lib/models/organization/description.dart new file mode 100644 index 0000000..7e6f5f9 --- /dev/null +++ b/frontend/pshared/lib/models/organization/description.dart @@ -0,0 +1,7 @@ +class OrganizationDescription { + final String? logoUrl; + + const OrganizationDescription({ + this.logoUrl, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/organization/employee.dart b/frontend/pshared/lib/models/organization/employee.dart new file mode 100644 index 0000000..1124596 --- /dev/null +++ b/frontend/pshared/lib/models/organization/employee.dart @@ -0,0 +1,4 @@ +import 'package:pshared/models/account/account.dart'; + + +typedef Employee = Account; \ No newline at end of file diff --git a/frontend/pshared/lib/models/organization/organization.dart b/frontend/pshared/lib/models/organization/organization.dart new file mode 100644 index 0000000..b04182a --- /dev/null +++ b/frontend/pshared/lib/models/organization/organization.dart @@ -0,0 +1,34 @@ +import 'package:pshared/models/storable.dart'; + + +class Organization implements Storable { + final Storable storable; + + @override + String get id => storable.id; + @override + DateTime get createdAt => storable.createdAt; + @override + DateTime get updatedAt => storable.updatedAt; + + final String timeZone; + final String? logoUrl; + + const Organization({ + required this.storable, + required this.timeZone, + this.logoUrl, + }); + + + Organization copyWith({ + String? name, + String? Function()? description, + String? timeZone, + String? Function()? logoUrl, + }) => Organization( + storable: storable, // Same Storable, same id + timeZone: timeZone ?? this.timeZone, + logoUrl: logoUrl != null ? logoUrl() : this.logoUrl, + ); +} diff --git a/frontend/pshared/lib/models/payment/methods/card.dart b/frontend/pshared/lib/models/payment/methods/card.dart new file mode 100644 index 0000000..6643e2b --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/card.dart @@ -0,0 +1,18 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/type.dart'; + + +class CardPaymentMethod extends PaymentMethodData { + @override + final PaymentType type = PaymentType.card; + + final String pan; + final String firstName; + final String lastName; + + CardPaymentMethod({ + required this.pan, + required this.firstName, + required this.lastName, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/payment/methods/data.dart b/frontend/pshared/lib/models/payment/methods/data.dart new file mode 100644 index 0000000..78780bb --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/data.dart @@ -0,0 +1,6 @@ +import 'package:pshared/models/payment/type.dart'; + + +abstract class PaymentMethodData { + PaymentType get type; +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/payment/methods/iban.dart b/frontend/pshared/lib/models/payment/methods/iban.dart new file mode 100644 index 0000000..83f7698 --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/iban.dart @@ -0,0 +1,20 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/type.dart'; + + +class IbanPaymentMethod extends PaymentMethodData { + @override + final PaymentType type = PaymentType.iban; + + final String iban; // e.g. DE89 3704 0044 0532 0130 00 + final String accountHolder; // Full name of the recipient + final String? bic; // Optional: for cross-border transfers + final String? bankName; // Optional: for UI clarity + + IbanPaymentMethod({ + required this.iban, + required this.accountHolder, + this.bic, + this.bankName, + }); +} diff --git a/frontend/pshared/lib/models/payment/methods/russian_bank.dart b/frontend/pshared/lib/models/payment/methods/russian_bank.dart new file mode 100644 index 0000000..70de691 --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/russian_bank.dart @@ -0,0 +1,26 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/type.dart'; + + +class RussianBankAccountPaymentMethod extends PaymentMethodData { + @override + final PaymentType type = PaymentType.bankAccount; + + final String recipientName; + final String inn; + final String kpp; + final String bankName; + final String bik; + final String accountNumber; + final String correspondentAccount; + + RussianBankAccountPaymentMethod({ + required this.recipientName, + required this.inn, + required this.kpp, + required this.bankName, + required this.bik, + required this.accountNumber, + required this.correspondentAccount, + }); +} diff --git a/frontend/pshared/lib/models/payment/methods/type.dart b/frontend/pshared/lib/models/payment/methods/type.dart new file mode 100644 index 0000000..cb55fc8 --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/type.dart @@ -0,0 +1,21 @@ +import 'package:pshared/models/payment/type.dart'; + + +class PaymentMethod { + PaymentMethod({ + required this.id, + required this.label, + required this.details, + required this.type, + this.isEnabled = true, + this.isMain = false, + }); + + final String id; + final String label; + final String details; + final PaymentType type; + + bool isEnabled; + bool isMain; +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/payment/methods/wallet.dart b/frontend/pshared/lib/models/payment/methods/wallet.dart new file mode 100644 index 0000000..587631e --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/wallet.dart @@ -0,0 +1,12 @@ +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/type.dart'; + + +class WalletPaymentMethod extends PaymentMethodData { + @override + final PaymentType type = PaymentType.wallet; + + final String walletId; + + WalletPaymentMethod({required this.walletId}); +} diff --git a/frontend/pshared/lib/models/payment/operation.dart b/frontend/pshared/lib/models/payment/operation.dart new file mode 100644 index 0000000..40ed8b6 --- /dev/null +++ b/frontend/pshared/lib/models/payment/operation.dart @@ -0,0 +1,30 @@ +import 'package:pshared/models/payment/status.dart'; + + +class OperationItem { + final OperationStatus status; + final String? fileName; + final double amount; + final String currency; + final double toAmount; + final String toCurrency; + final String payId; + final String? cardNumber; + final String name; + final DateTime date; + final String comment; + + OperationItem({ + required this.status, + this.fileName, + required this.amount, + required this.currency, + required this.toAmount, + required this.toCurrency, + required this.payId, + this.cardNumber, + required this.name, + required this.date, + required this.comment, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/payment/status.dart b/frontend/pshared/lib/models/payment/status.dart new file mode 100644 index 0000000..68acfda --- /dev/null +++ b/frontend/pshared/lib/models/payment/status.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import 'package:pshared/generated/i18n/ps_localizations.dart'; + +enum OperationStatus { + processing, + success, + error, +} + + +extension OperationStatusX on OperationStatus { + /// Returns the localized string for this status, + /// e.g. “Processing”, “Success”, “Error”. + String localized(BuildContext context) { + final loc = PSLocalizations.of(context)!; + switch (this) { + case OperationStatus.processing: + return loc.operationStatusProcessing; + case OperationStatus.success: + return loc.operationStatusSuccess; + case OperationStatus.error: + return loc.operationStatusError; + } + } +} diff --git a/frontend/pshared/lib/models/payment/type.dart b/frontend/pshared/lib/models/payment/type.dart new file mode 100644 index 0000000..0dd75d6 --- /dev/null +++ b/frontend/pshared/lib/models/payment/type.dart @@ -0,0 +1,6 @@ +enum PaymentType { + bankAccount, + iban, + wallet, + card, +} diff --git a/frontend/pshared/lib/models/payment/upload_history_item.dart b/frontend/pshared/lib/models/payment/upload_history_item.dart new file mode 100644 index 0000000..3c131d8 --- /dev/null +++ b/frontend/pshared/lib/models/payment/upload_history_item.dart @@ -0,0 +1,11 @@ +class UploadHistoryItem { + final String name; + final String status; + final String time; + + UploadHistoryItem({ + required this.name, + required this.status, + required this.time, + }); +} diff --git a/frontend/pshared/lib/models/permission_bound.dart b/frontend/pshared/lib/models/permission_bound.dart new file mode 100644 index 0000000..6618f26 --- /dev/null +++ b/frontend/pshared/lib/models/permission_bound.dart @@ -0,0 +1,22 @@ +import 'package:pshared/config/constants.dart'; + + +abstract class PermissionBound { + String get permissionRef; + String get organizationRef; +} + +class _PermissionBoundImp implements PermissionBound { + @override + final String permissionRef; + @override + final String organizationRef; + + const _PermissionBoundImp({ + required this.permissionRef, + required this.organizationRef, + }); +} + +PermissionBound newPermissionBound({ required String organizationRef, String? permissionRef}) => + _PermissionBoundImp(permissionRef: permissionRef ?? Constants.nilObjectRef, organizationRef: organizationRef); \ No newline at end of file diff --git a/frontend/pshared/lib/models/permission_bound_storable.dart b/frontend/pshared/lib/models/permission_bound_storable.dart new file mode 100644 index 0000000..4ee0d63 --- /dev/null +++ b/frontend/pshared/lib/models/permission_bound_storable.dart @@ -0,0 +1,6 @@ +import 'package:pshared/models/permission_bound.dart'; +import 'package:pshared/models/storable.dart'; + + +abstract class PermissionBoundStorable implements PermissionBound, Storable { +} diff --git a/frontend/pshared/lib/models/permissions/access.dart b/frontend/pshared/lib/models/permissions/access.dart new file mode 100644 index 0000000..4bf5031 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/access.dart @@ -0,0 +1,13 @@ +import 'package:pshared/models/permissions/data/permissions.dart'; +import 'package:pshared/models/permissions/descriptions/permissions.dart'; + + +class UserAccess { + final PermissionsDescription descriptions; + final PermissionsData permissions; + + const UserAccess({ + required this.descriptions, + required this.permissions, + }); +} diff --git a/frontend/pshared/lib/models/permissions/action.dart b/frontend/pshared/lib/models/permissions/action.dart new file mode 100644 index 0000000..1d99a6a --- /dev/null +++ b/frontend/pshared/lib/models/permissions/action.dart @@ -0,0 +1,15 @@ +enum Action { + create, + read, + update, + delete, +} + +extension ActionExtension on Action { + String toShortString() => toString().split('.').last; + + static Action fromString(String value) => Action.values.firstWhere( + (e) => e.toShortString() == value, + orElse: () => throw ArgumentError('Invalid action: $value'), + ); +} diff --git a/frontend/pshared/lib/models/permissions/action_effect.dart b/frontend/pshared/lib/models/permissions/action_effect.dart new file mode 100644 index 0000000..b6be501 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/action_effect.dart @@ -0,0 +1,13 @@ +import 'package:pshared/models/permissions/action.dart'; +import 'package:pshared/models/permissions/effect.dart'; + + +class ActionEffect { + final Action action; // The action allowed or denied + final Effect effect; // The effect of the policy ("allow" or "deny") + + const ActionEffect({ + required this.action, + required this.effect, + }); +} diff --git a/frontend/pshared/lib/models/permissions/data/permission.dart b/frontend/pshared/lib/models/permissions/data/permission.dart new file mode 100644 index 0000000..fc8c4d8 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/data/permission.dart @@ -0,0 +1,12 @@ +import 'package:pshared/models/permissions/data/policy.dart'; + + +class Permission { + final Policy policy; + final String accountRef; + + const Permission({ + required this.policy, + required this.accountRef, + }); +} diff --git a/frontend/pshared/lib/models/permissions/data/permissions.dart b/frontend/pshared/lib/models/permissions/data/permissions.dart new file mode 100644 index 0000000..ef97108 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/data/permissions.dart @@ -0,0 +1,16 @@ +import 'package:pshared/models/permissions/data/permission.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; +import 'package:pshared/models/permissions/data/role.dart'; + + +class PermissionsData { + final List roles; + final List policies; + final List permissions; + + const PermissionsData({ + required this.roles, + required this.policies, + required this.permissions, + }); +} diff --git a/frontend/pshared/lib/models/permissions/data/policy.dart b/frontend/pshared/lib/models/permissions/data/policy.dart new file mode 100644 index 0000000..30c0ec6 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/data/policy.dart @@ -0,0 +1,18 @@ +import 'package:pshared/models/permissions/action_effect.dart'; + + +class Policy { + final String roleDescriptionRef; + final String organizationRef; + final String descriptionRef; + final String? objectRef; + final ActionEffect effect; + + const Policy({ + required this.roleDescriptionRef, + required this.organizationRef, + required this.descriptionRef, + required this.objectRef, + required this.effect, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/permissions/data/role.dart b/frontend/pshared/lib/models/permissions/data/role.dart new file mode 100644 index 0000000..536ef17 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/data/role.dart @@ -0,0 +1,11 @@ +class Role { + final String accountRef; + final String organizationRef; + final String descriptionRef; + + const Role({ + required this.accountRef, + required this.descriptionRef, + required this.organizationRef, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/permissions/descriptions/permissions.dart b/frontend/pshared/lib/models/permissions/descriptions/permissions.dart new file mode 100644 index 0000000..5b08635 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/descriptions/permissions.dart @@ -0,0 +1,13 @@ +import 'package:pshared/models/permissions/descriptions/policy.dart'; +import 'package:pshared/models/permissions/descriptions/role.dart'; + + +class PermissionsDescription { + final List roles; + final List policies; + + const PermissionsDescription({ + required this.roles, + required this.policies, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/permissions/descriptions/policy.dart b/frontend/pshared/lib/models/permissions/descriptions/policy.dart new file mode 100644 index 0000000..6aecef0 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/descriptions/policy.dart @@ -0,0 +1,22 @@ +import 'package:pshared/models/resources.dart'; +import 'package:pshared/models/storable.dart'; + + +class PolicyDescription implements Storable { + final Storable storable; + final List? resourceTypes; + final String? organizationRef; + + @override + String get id => storable.id; + @override + DateTime get createdAt => storable.createdAt; + @override + DateTime get updatedAt => storable.updatedAt; + + const PolicyDescription({ + required this.storable, + required this.resourceTypes, + required this.organizationRef, + }); +} \ No newline at end of file diff --git a/frontend/pshared/lib/models/permissions/descriptions/role.dart b/frontend/pshared/lib/models/permissions/descriptions/role.dart new file mode 100644 index 0000000..ecde4fb --- /dev/null +++ b/frontend/pshared/lib/models/permissions/descriptions/role.dart @@ -0,0 +1,27 @@ +import 'package:pshared/models/storable.dart'; + + +class RoleDescription implements Storable { + final Storable storable; + + @override + String get id => storable.id; + @override + DateTime get createdAt => storable.createdAt; + @override + DateTime get updatedAt => storable.updatedAt; + + final String organizationRef; + + const RoleDescription({ + required this.storable, + required this.organizationRef, + }); + + factory RoleDescription.build({ + required String organizationRef, + }) => RoleDescription( + storable: newStorable(), + organizationRef: organizationRef + ); +} diff --git a/frontend/pshared/lib/models/permissions/effect.dart b/frontend/pshared/lib/models/permissions/effect.dart new file mode 100644 index 0000000..8019772 --- /dev/null +++ b/frontend/pshared/lib/models/permissions/effect.dart @@ -0,0 +1,13 @@ +enum Effect { + allow, + deny, +} + +extension EffectExtension on Effect { + String toShortString() => toString().split('.').last; + + static Effect fromString(String value) => Effect.values.firstWhere( + (e) => e.toShortString() == value, + orElse: () => throw ArgumentError('Invalid effect: $value'), + ); +} diff --git a/frontend/pshared/lib/models/pfe/services.dart b/frontend/pshared/lib/models/pfe/services.dart new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pshared/lib/models/recipient/filter.dart b/frontend/pshared/lib/models/recipient/filter.dart new file mode 100644 index 0000000..52d3b0f --- /dev/null +++ b/frontend/pshared/lib/models/recipient/filter.dart @@ -0,0 +1 @@ +enum RecipientFilter { all, ready, registered, notRegistered } \ No newline at end of file diff --git a/frontend/pshared/lib/models/recipient/recipient.dart b/frontend/pshared/lib/models/recipient/recipient.dart new file mode 100644 index 0000000..f131920 --- /dev/null +++ b/frontend/pshared/lib/models/recipient/recipient.dart @@ -0,0 +1,78 @@ +import 'package:pshared/models/payment/methods/card.dart'; +import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/russian_bank.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; + + +class Recipient { + final String? avatarUrl; // network URL / local asset + final String name; + final String email; + final RecipientStatus status; + final RecipientType type; + final CardPaymentMethod? card; + final IbanPaymentMethod? iban; + final RussianBankAccountPaymentMethod? bank; + final WalletPaymentMethod? wallet; + + const Recipient({ + this.avatarUrl, + required this.name, + required this.email, + required this.status, + required this.type, + this.card, + this.iban, + this.bank, + this.wallet, + }); + + /// Convenience factory for quickly creating mock recipients. + factory Recipient.mock({ + required String name, + required String email, + required RecipientStatus status, + required RecipientType type, + CardPaymentMethod? card, + IbanPaymentMethod? iban, + RussianBankAccountPaymentMethod? bank, + WalletPaymentMethod? wallet, + }) => + Recipient( + avatarUrl: null, + name: name, + email: email, + status: status, + type: type, + card: card, + iban: iban, + bank: bank, + wallet: wallet, + ); + + bool matchesQuery(String q) { + final searchable = [ + name, + email, + card?.pan, + card?.firstName, + card?.lastName, + iban?.iban, + iban?.accountHolder, + iban?.bic, + iban?.bankName, + bank?.accountNumber, + bank?.recipientName, + bank?.inn, + bank?.kpp, + bank?.bankName, + bank?.bik, + bank?.correspondentAccount, + wallet?.walletId, + ]; + + return searchable.any((field) => field?.toLowerCase().contains(q) ?? false); + } +} diff --git a/frontend/pshared/lib/models/recipient/status.dart b/frontend/pshared/lib/models/recipient/status.dart new file mode 100644 index 0000000..ffbf681 --- /dev/null +++ b/frontend/pshared/lib/models/recipient/status.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pshared/generated/i18n/ps_localizations.dart'; + + +/// Possible payout readiness states. +enum RecipientStatus { ready, registered, notRegistered } + +extension RecipientStatusExtension on RecipientStatus { + /// Human-readable, **localized** label for display in the UI. + String label(BuildContext context) { + final l10n = PSLocalizations.of(context)!; + switch (this) { + case RecipientStatus.ready: + return l10n.statusReady; + case RecipientStatus.registered: + return l10n.statusRegistered; + case RecipientStatus.notRegistered: + return l10n.statusNotRegistered; + } + } +} diff --git a/frontend/pshared/lib/models/recipient/type.dart b/frontend/pshared/lib/models/recipient/type.dart new file mode 100644 index 0000000..e71c86b --- /dev/null +++ b/frontend/pshared/lib/models/recipient/type.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pshared/generated/i18n/ps_localizations.dart'; + + +/// Indicates whether you (internal) or the other party (external) manage payout data. +enum RecipientType { internal, external } + +extension RecipientTypeExtension on RecipientType { + /// Localized label – no opaque abbreviations. + String label(BuildContext context) => + this == RecipientType.internal + ? PSLocalizations.of(context)!.typeInternal + : PSLocalizations.of(context)!.typeExternal; +} diff --git a/frontend/pshared/lib/models/resources.dart b/frontend/pshared/lib/models/resources.dart new file mode 100644 index 0000000..851f2d6 --- /dev/null +++ b/frontend/pshared/lib/models/resources.dart @@ -0,0 +1,107 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// Represents various resource types (mirroring your Go "Type" constants). +enum ResourceType { + /// Represents user accounts in the system + @JsonValue('accounts') + accounts, + + /// Represents analytics integration with Amplitude + @JsonValue('amplitude') + amplitude, + + /// Represents automation workflows + @JsonValue('automations') + automations, + + /// Tracks changes made to resources + @JsonValue('changes') + changes, + + /// Represents client information + @JsonValue('clients') + clients, + + /// Represents comments on tasks or other resources + @JsonValue('comments') + comments, + + /// Represents invitations sent to users + @JsonValue('invitations') + invitations, + + /// Represents invoices + @JsonValue('invoices') + invoices, + + /// Represents logos for organizations or projects + @JsonValue('logo') + logo, + + /// Represents notifications sent to users + @JsonValue('notifications') + notifications, + + /// Represents organizations in the system + @JsonValue('organizations') + organizations, + + /// Represents permissions service + @JsonValue('permissions') + permissions, + + /// Represents access control policies + @JsonValue('policies') + policies, + + /// Represents task or project priorities + @JsonValue('priorities') + priorities, + + /// Represents priority groups + @JsonValue('priority_groups') + priorityGroups, + + /// Represents projects managed in the system + @JsonValue('projects') + projects, + + @JsonValue('properties') + properties, + + /// Represents reactions + @JsonValue('reactions') + reactions, + + /// Represents refresh tokens for authentication + @JsonValue('refresh_tokens') + refreshTokens, + + /// Represents roles in access control + @JsonValue('roles') + roles, + + /// Represents statuses of tasks or projects + @JsonValue('statuses') + statuses, + + /// Represents steps in workflows or processes + @JsonValue('steps') + steps, + + /// Represents tasks managed in the system + @JsonValue('tasks') + tasks, + + /// Represents teams managed in the system + @JsonValue('teams') + teams, + + /// Represents workflows for tasks or projects + @JsonValue('workflows') + workflows, + + /// Represents workspaces containing projects and teams + @JsonValue('workspaces') + workspaces; +} diff --git a/frontend/pshared/lib/models/settings/localizations.dart b/frontend/pshared/lib/models/settings/localizations.dart new file mode 100644 index 0000000..f85fc64 --- /dev/null +++ b/frontend/pshared/lib/models/settings/localizations.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + + +typedef LangLocalization = Map; + +String _translation(LangLocalization loc, String key) { + return loc[key] ?? ''; +} + +typedef Localizations = Map; + +typedef Localizer = String Function(BuildContext); + +class Localization { + + static const String keyHint = 'hint'; + static const String keyLink = 'link'; + static const String keyName = 'name'; + static const String keyError = 'error'; + static const String keyAddress = 'address'; + static const String keyDetails = 'details'; + static const String keyRoute = 'route'; + static const String keyLocationName = 'location_name'; + + static String _localizeImp(Localizations localizations, String locale, String Function(LangLocalization) functor) { + final localization = localizations[locale]; + if (localization != null) { + return functor(localization); + } + return ''; + } + + static String _localize(Localizations localizations, String locale, String Function(LangLocalization) functor, {String? fallback}) { + final res = _localizeImp(localizations, locale, functor); + return res.isNotEmpty ? res : (fallback ?? ''); + } + + static bool localizationExists(Localizations loc, String locale) { + return loc.containsKey(locale); + } + + static String hint(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyHint), fallback: fallback); + } + + static String link(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyLink), fallback: fallback); + } + + static String name(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyName), fallback: fallback); + } + + static String error(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyError), fallback: fallback); + } + + static String address(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyAddress), fallback: fallback); + } + + static String details(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyDetails), fallback: fallback); + } + + static String route(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyRoute), fallback: fallback); + } + + static String locationName(Localizations loc, String locale, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, keyLocationName), fallback: fallback); + } + + static String translate(Localizations loc, String locale, String key, {String? fallback}) { + return _localize(loc, locale, (localization) => _translation(localization, key), fallback: fallback); + } +} diff --git a/frontend/pshared/lib/models/settings/time_validity.dart b/frontend/pshared/lib/models/settings/time_validity.dart new file mode 100644 index 0000000..ebd0e31 --- /dev/null +++ b/frontend/pshared/lib/models/settings/time_validity.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'time_validity.g.dart'; + + +@JsonSerializable() +class TimeValidity { + final DateTime? start; + final DateTime? expiry; + + const TimeValidity({this.start, this.expiry}); + + bool get isExpired => expiry?.isBefore(DateTime.now()) ?? false; + bool get isNotStarted => start?.isAfter(DateTime.now()) ?? false; + bool get isActive => (!isNotStarted) && (!isExpired); + + TimeValidity copyWith({ + DateTime? Function()? start, + DateTime? Function()? expiry, + }) => TimeValidity( + start: start == null ? this.start : start(), + expiry: expiry == null ? this.expiry : expiry(), + ); + + factory TimeValidity.fromJson(Map json) => _$TimeValidityFromJson(json); + Map toJson() => _$TimeValidityToJson(this); +} diff --git a/frontend/pshared/lib/models/storable.dart b/frontend/pshared/lib/models/storable.dart new file mode 100644 index 0000000..ce95482 --- /dev/null +++ b/frontend/pshared/lib/models/storable.dart @@ -0,0 +1,30 @@ +import 'package:pshared/config/constants.dart'; + + +abstract class Storable { + String get id; + DateTime get createdAt; + DateTime get updatedAt; +} + +class _StorableImp implements Storable { + @override + final String id; + @override + final DateTime createdAt; + @override + final DateTime updatedAt; + + const _StorableImp({ + required this.id, + required this.createdAt, + required this.updatedAt, + }); + +} + +Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => _StorableImp( + id: id ?? Constants.nilObjectRef, + createdAt: createdAt ?? DateTime.now().toUtc(), + updatedAt: updatedAt ?? DateTime.now().toUtc(), +); diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart new file mode 100644 index 0000000..111ae4b --- /dev/null +++ b/frontend/pshared/lib/provider/account.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pshared/api/errors/unauthorized.dart'; +import 'package:pshared/api/requests/signup.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/provider/exception.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/account.dart'; + + +class AccountProvider extends ChangeNotifier { + // The resource now wraps our Account? state along with its loading/error state. + Resource _resource = Resource(data: null); + Resource get resource => _resource; + + Account? get account => _resource.data; + bool get isLoggedIn => account != null; + bool get isLoading => _resource.isLoading; + Object? get error => _resource.error; + + // Private helper to update the resource and notify listeners. + void _setResource(Resource newResource) { + _resource = newResource; + notifyListeners(); + } + + + Future login({ + required String email, + required String password, + required String locale, + }) async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final acc = await AccountService.login(email, password, locale); + _setResource(Resource(data: acc, isLoading: false)); + return acc; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future restore() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final acc = await AccountService.restore(); + _setResource(Resource(data: acc, isLoading: false)); + return acc; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future signup( + String name, + String login, + String password, + String locale, + String organizationName, + String timezone, + ) async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + await AccountService.signup( + SignupRequest.build( + name: name, + login: login.trim().toLowerCase(), + password: password, + locale: locale, + organizationName: organizationName, + organizationTimeZone: timezone, + ), + ); + // Signup might not automatically log in the user, + // so we just mark the request as complete. + _setResource(_resource.copyWith(isLoading: false)); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future logout() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + await AccountService.logout(); + _setResource(Resource(data: null, isLoading: false)); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future update({ + String? locale, + String? avatarUrl, + String? notificationFrequency, + }) async { + if (account == null) throw ErrorUnauthorized(); + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final updated = await AccountService.update( + account!.copyWith( + avatarUrl: () => avatarUrl ?? account!.avatarUrl, + locale: locale ?? account!.locale, + ), + ); + _setResource(Resource(data: updated, isLoading: false)); + return updated; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future changePassword(String oldPassword, String newPassword) async { + if (account == null) throw ErrorUnauthorized(); + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final updated = await AccountService.changePassword(oldPassword, newPassword); + _setResource(Resource(data: updated, isLoading: false)); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future uploadAvatar(XFile avatarFile) async { + if (account == null) throw ErrorUnauthorized(); + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final avatarUrl = await AccountService.uploadAvatar(account!.id, avatarFile); + // Reuse the update method to update the avatar URL. + return update(avatarUrl: avatarUrl); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } +} diff --git a/frontend/pshared/lib/provider/accounts/employees.dart b/frontend/pshared/lib/provider/accounts/employees.dart new file mode 100644 index 0000000..f2c373d --- /dev/null +++ b/frontend/pshared/lib/provider/accounts/employees.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/models/organization/employee.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/accounts/employees.dart'; + + +class EmployeesProvider extends ChangeNotifier { + + Resource> _employees = Resource>(data: []); + List get employees => _employees.data ?? []; + bool get isLoading => _employees.isLoading; + Object? get error => _employees.error; + Employee? getEmployee(String? employeeRef) => employees.firstWhereOrNull((employee) => employee.id == employeeRef); + + + bool Function(Employee)? _filterPredicate; + + List get filteredItems => _filterPredicate != null + ? employees.where(_filterPredicate!).toList() + : employees; + + void setFilterPredicate(bool Function(Employee)? predicate) { + _filterPredicate = predicate; + notifyListeners(); + } + + void clearFilter() => setFilterPredicate(null); + + void updateProviders(OrganizationsProvider organizations) { + load(organizations.current.id); + } + + Future> load(String organizationRef) async { + _employees = _employees.copyWith(isLoading: true, error: null); + notifyListeners(); + + try { + final fetchedEmployees = await EmployeesService.list(organizationRef); + _employees = _employees.copyWith( + data: fetchedEmployees, + isLoading: false, + error: null, + ); + } catch (e) { + _employees = _employees.copyWith( + error: e is Exception ? e : Exception('Unknown error: ${e.toString()}'), + isLoading: false, + ); + } + + notifyListeners(); + return employees; + } +} diff --git a/frontend/pshared/lib/provider/exception.dart b/frontend/pshared/lib/provider/exception.dart new file mode 100644 index 0000000..22ba3e4 --- /dev/null +++ b/frontend/pshared/lib/provider/exception.dart @@ -0,0 +1,3 @@ +Exception toException(Object e) { + return e is Exception ? e : Exception(e.toString()); +} \ No newline at end of file diff --git a/frontend/pshared/lib/provider/locale.dart b/frontend/pshared/lib/provider/locale.dart new file mode 100644 index 0000000..277f033 --- /dev/null +++ b/frontend/pshared/lib/provider/locale.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/config/constants.dart'; + + +class LocaleProvider with ChangeNotifier { + Locale _locale = Constants.defaultLocale; + + Locale stringToLocale(String localeString) { + var parts = localeString.split(RegExp(r'[-_]')); + return (parts.length > 1) ? Locale(parts[0], parts[1]) : Locale(parts[0]); + } + + LocaleProvider(String? localeCode) { + if (localeCode != null) { + _locale = stringToLocale(localeCode); + } + } + + Locale get locale => _locale; + + void setLocale(Locale locale) { + if (_locale == locale) return; + + _locale = locale; + notifyListeners(); + } +} + diff --git a/frontend/pshared/lib/provider/organizations.dart b/frontend/pshared/lib/provider/organizations.dart new file mode 100644 index 0000000..c338885 --- /dev/null +++ b/frontend/pshared/lib/provider/organizations.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/config/constants.dart'; +import 'package:pshared/models/organization/organization.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/provider/exception.dart'; +import 'package:pshared/service/organization.dart'; +import 'package:pshared/service/secure_storage.dart'; + + +class OrganizationsProvider extends ChangeNotifier { + Resource> _resource = Resource(data: []); + Resource> get resource => _resource; + + List get organizations => _resource.data ?? []; + String? _currentOrg; + + Organization get current => isOrganizationSet ? _current! : throw StateError('Organization is not set'); + + Organization? _org(String? orgRef) => organizations.firstWhereOrNull((org) => org.id == orgRef); + Organization? get _current => _org(_currentOrg); + + bool get isOrganizationSet => _current != null; + bool get isLoading => _resource.isLoading; + Object? get error => _resource.error; + + void _setResource(Resource> newResource) { + _resource = newResource; + notifyListeners(); + } + + Future> load() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final orgs = await OrganizationService.list(); + // fetch stored org + String? org = await SecureStorageService.get(Constants.currentOrgKey); + // check stored org availability + org = orgs.firstWhereOrNull((o) => o.id == org)?.id; + // fallback if org is not set or not available + org ??= orgs.first.id; + await setCurrentOrganization(org); + _setResource(Resource(data: orgs, isLoading: false)); + return orgs; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future loadByInvitation(String invitationRef) async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final org = await OrganizationService.loadByInvitation(invitationRef); + await setCurrentOrganization(org.id); + _setResource(Resource(data: [org], isLoading: false)); + return org; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + bool _setCurrentOrganization(String? orgRef) { + final organizationRef = _org(orgRef)?.id; + if (organizationRef == null) return false; + + _currentOrg = organizationRef; + return true; + } + + Future setCurrentOrganization(String? orgRef) async { + if (!_setCurrentOrganization(orgRef)) return false; + await SecureStorageService.set(Constants.currentOrgKey, orgRef); + notifyListeners(); + return true; + } +} diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart new file mode 100644 index 0000000..98091d4 --- /dev/null +++ b/frontend/pshared/lib/provider/permissions.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/api/requests/change_role.dart'; +import 'package:pshared/models/permissions/access.dart'; +import 'package:pshared/models/permissions/action.dart' as perm; +import 'package:pshared/models/permissions/data/permission.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; +import 'package:pshared/models/permissions/data/role.dart'; +import 'package:pshared/models/permissions/descriptions/policy.dart'; +import 'package:pshared/models/permissions/descriptions/role.dart'; +import 'package:pshared/models/permissions/effect.dart'; +import 'package:pshared/models/resources.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/permissions.dart'; + + +class PermissionsProvider extends ChangeNotifier { + Resource _userAccess = Resource(data: null, isLoading: false, error: null); + late OrganizationsProvider _organizations; + + void update(OrganizationsProvider venue) { + _organizations = venue; + } + + // Generic wrapper to perform service calls and reload state + Future _performServiceCall(Future Function() operation) async { + try { + await operation(); + return await load(); + } catch (e) { + _userAccess = _userAccess.copyWith( + error: e is Exception ? e : Exception(e.toString()), + isLoading: false, + ); + notifyListeners(); + return _userAccess.data; + } + } + + /// Load the [UserAccess] for the current venue. + Future load() async { + _userAccess = _userAccess.copyWith(isLoading: true, error: null); + notifyListeners(); + + try { + final orgRef = _organizations.current.id; + final access = await PermissionsService.load(orgRef); + _userAccess = _userAccess.copyWith(data: access, isLoading: false); + + if (canRead(ResourceType.roles)) { + final allAccess = await PermissionsService.loadAll(orgRef); + _userAccess = _userAccess.copyWith(data: allAccess, isLoading: false); + } + } catch (e) { + _userAccess = _userAccess.copyWith( + error: e is Exception ? e : Exception(e.toString()), + isLoading: false, + ); + } + + notifyListeners(); + return _userAccess.data; + } + + Future changeRole(String accountRef, String newRoleDescRef) async { + final currentRole = roles.firstWhereOrNull((r) => r.accountRef == accountRef); + final currentDesc = currentRole != null + ? roleDescriptions.firstWhereOrNull((d) => d.storable.id == currentRole.descriptionRef) + : null; + + if (currentRole == null || currentDesc == null || currentDesc.storable.id == newRoleDescRef) { + return _userAccess.data; + } + return _performServiceCall(() => PermissionsService.changeRole( + _organizations.current.id, + ChangeRole(accountRef: accountRef, newRoleDescriptionRef: newRoleDescRef), + )); + } + + Future deleteRoleDescription(String descRef) { + return _performServiceCall(() => PermissionsService.deleteRoleDescription(descRef)); + } + + Future createPermissions(List policies) { + return _performServiceCall(() => PermissionsService.createPolicies(policies)); + } + + Future deletePermissions(List policies) { + return _performServiceCall(() => PermissionsService.deletePolicies(policies)); + } + + Future changePermissions(List add, List remove) { + return _performServiceCall(() => PermissionsService.changePolicies(add, remove)); + } + + // -- Data getters -- + Set extractResourceTypes(Iterable descriptions) => descriptions.expand((policy) => policy.resourceTypes ?? []).toSet(); + + Set get resources => Set.unmodifiable(extractResourceTypes(policyDescriptions)); + + Set getRoleResources(String roleDescRef) => Set.unmodifiable( + extractResourceTypes( + getRolePermissions(roleDescRef) + .map((p) => getPolicyDescription(p.policy.descriptionRef)) + .whereType(), + ), + ); + + String? getPolicyDescriptionRef(ResourceType resource) => policyDescriptions.firstWhereOrNull((p) => p.resourceTypes?.contains(resource) ?? false)?.storable.id; + + List get policyDescriptions => List.unmodifiable(_userAccess.data?.descriptions.policies ?? []); + List get roleDescriptions => List.unmodifiable(_userAccess.data?.descriptions.roles ?? []); + List get permissions => List.unmodifiable(_userAccess.data?.permissions.permissions ?? []); + List get policies => List.unmodifiable(_userAccess.data?.permissions.policies ?? []); + List get roles => List.unmodifiable(_userAccess.data?.permissions.roles ?? []); + + Role? getRole(String accountRef) => roles.firstWhereOrNull((r) => r.accountRef == accountRef); + RoleDescription? getRoleDescription(String descRef) => roleDescriptions.firstWhereOrNull((d) => d.storable.id == descRef); + List getRoles(String accountRef) => roles.where((r) => r.accountRef == accountRef).toList(); + List getRolePolicies(String roleRef) => policies.where((p) => p.roleDescriptionRef == roleRef).toList(); + List getRolePermissions(String descRef) => permissions.where((p) => p.policy.roleDescriptionRef == descRef).toList(); + PolicyDescription? getPolicyDescription(String policyRef) => policyDescriptions.firstWhereOrNull((p) => p.storable.id == policyRef); + + // -- Permission checks -- + bool get isLoading => _userAccess.isLoading; + bool get isReady => !_userAccess.isLoading && error == null; + Exception? get error => _userAccess.error; + + bool _hasMatchingPermission( + PolicyDescription pd, + Effect effect, + perm.Action? action, { + Object? objectRef, + }) => permissions.firstWhereOrNull( + (p) => + p.policy.descriptionRef == pd.storable.id && + p.policy.effect.effect == effect && + (action == null || p.policy.effect.action == action) && + (p.policy.objectRef == null || p.policy.objectRef == objectRef), + ) != null; + + bool canAccessResource( + ResourceType resource, { + perm.Action? action, + Object? objectRef, + }) { + final orgId = _organizations.current.id; + final pd = policyDescriptions.firstWhereOrNull( + (policy) => + (policy.resourceTypes?.contains(resource) ?? false) && + (policy.organizationRef == null || policy.organizationRef == orgId), + ); + if (pd == null) return false; + if (_hasMatchingPermission(pd, Effect.deny, action, objectRef: objectRef)) return false; + return _hasMatchingPermission(pd, Effect.allow, action, objectRef: objectRef); + } + + bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef); + bool canUpdate(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.update, objectRef: objectRef); + bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef); + bool canCreate(ResourceType r) => canAccessResource(r, action: perm.Action.create); +} diff --git a/frontend/pshared/lib/provider/pfe/provider.dart b/frontend/pshared/lib/provider/pfe/provider.dart new file mode 100644 index 0000000..6fff15b --- /dev/null +++ b/frontend/pshared/lib/provider/pfe/provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/service/pfe/service.dart'; +import 'package:pshared/provider/exception.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/account.dart'; + + +class PfeProvider extends ChangeNotifier { + // The resource now wraps our Account? state along with its loading/error state. + Resource _resource = Resource(data: null); + Resource get resource => _resource; + + String? get session => _resource.data; + bool get isLoggedIn => session != null; + bool get isLoading => _resource.isLoading; + Object? get error => _resource.error; + + // Private helper to update the resource and notify listeners. + void _setResource(Resource newResource) { + _resource = newResource; + notifyListeners(); + } + + + Future login({ + required String email, + required String password, + }) async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final acc = await PfeService.login(email, password); + _setResource(Resource(data: acc, isLoading: false)); + return acc; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future logout() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + await AccountService.logout(); + _setResource(Resource(data: null, isLoading: false)); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } +} diff --git a/frontend/pshared/lib/provider/resource.dart b/frontend/pshared/lib/provider/resource.dart new file mode 100644 index 0000000..6d127f2 --- /dev/null +++ b/frontend/pshared/lib/provider/resource.dart @@ -0,0 +1,15 @@ +class Resource { + final T? data; + final bool isLoading; + final Exception? error; + + Resource({this.data, this.isLoading = false, this.error}); + + Resource copyWith({T? data, bool? isLoading, Exception? error}) { + return Resource( + data: data ?? this.data, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + } +} diff --git a/frontend/pshared/lib/provider/services.dart b/frontend/pshared/lib/provider/services.dart new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pshared/lib/provider/template.dart b/frontend/pshared/lib/provider/template.dart new file mode 100644 index 0000000..0a8d819 --- /dev/null +++ b/frontend/pshared/lib/provider/template.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/models/permission_bound_storable.dart'; +import 'package:pshared/provider/exception.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/template.dart'; + + +List mergeLists({ + required List lhs, + required List rhs, + required Comparable Function(T) getKey, // Extracts ID dynamically + required int Function(T, T) compare, + required T Function(T, T) merge, +}) { + final result = []; + final map = {for (var item in lhs) getKey(item): item}; + + for (var updated in rhs) { + final key = getKey(updated); + map[key] = merge(map[key] ?? updated, updated); + } + + result.addAll(map.values); + result.sort(compare); + return result; +} + +/// A generic provider that wraps a [BasicService] instance +/// to manage state (loading, error, data) without re‑implementing service logic. +class GenericProvider extends ChangeNotifier { + final BasicService service; + + Resource> _resource = Resource(data: []); + Resource> get resource => _resource; + + List get items => List.unmodifiable(_resource.data ?? []); + bool get isLoading => _resource.isLoading; + bool get isEmpty => items.isEmpty; + Object? get error => _resource.error; + + String? _currentObjectRef; // Stores the currently selected project ref + T? get currentObject => _resource.data?.firstWhereOrNull( + (object) => object.id == _currentObjectRef, + ); + + T? getItemById(String id) => items.firstWhereOrNull((item) => item.id == id); + + GenericProvider({required this.service}); + + + bool Function(T)? _filterPredicate; + + List get filteredItems => _filterPredicate != null ? items.where(_filterPredicate!).toList() : items; + + void setFilterPredicate(bool Function(T)? predicate) { + _filterPredicate = predicate; + notifyListeners(); + } + + void clearFilter() => setFilterPredicate(null); + + void _setResource(Resource> newResource) { + _resource = newResource; + notifyListeners(); + } + + Future loadFuture(Future> future) async { + _setResource(_resource.copyWith(isLoading: true)); + try { + final list = await future; + _setResource(Resource(data: list, isLoading: false)); + } catch (e) { + _setResource( + _resource.copyWith(isLoading: false, error: toException(e)), + ); + rethrow; + } + } + + Future load(String organizationRef, String? parentRef) async { + if (parentRef != null) { + return loadFuture(service.list(organizationRef, parentRef)); + } + } + + Future loadItem(String itemRef) async { + return loadFuture((() async => [await service.get(itemRef)])()); + } + + + List merge(List rhs) => mergeLists( + lhs: items, + rhs: rhs, + getKey: (item) => item.id, // Key extractor + compare: (a, b) => a.id.compareTo(b.id), // Sorting logic + merge: (existing, updated) => updated, // Replace with the updated version + ); + + Future get(String objectRef) async { + _setResource(_resource.copyWith(isLoading: true)); + try { + final item = await service.get(objectRef); + _setResource(Resource(data: merge([item]), isLoading: false)); + return item; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future createObject(String organizationRef, Map request) async { + _setResource(_resource.copyWith(isLoading: true)); + try { + final newObject = await service.create(organizationRef, request); + _setResource(Resource(data: [...items, ...newObject], isLoading: false)); + return newObject.first; + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future update(Map request) async { + _setResource(_resource.copyWith(isLoading: true)); + try { + final list = await service.update(request); + _setResource(Resource(data: merge(list), isLoading: false)); + } catch (e) { + _setResource(_resource.copyWith(isLoading: false, error: toException(e))); + rethrow; + } + } + + Future delete(String objectRef) async { + _setResource(_resource.copyWith(isLoading: true)); + + try { + await service.delete(objectRef); + if (_currentObjectRef == objectRef) { + _currentObjectRef = null; + } + + _setResource(Resource( + data: _resource.data?.where((p) => p.id != objectRef).toList(), + isLoading: false, + )); + } catch (e) { + _setResource(Resource(data: _resource.data, isLoading: false, error: toException(e))); + rethrow; + } + } + + bool setCurrentObject(String? objectRef) { + if (objectRef == null) { + _currentObjectRef = null; + notifyListeners(); + return true; + } + if (_resource.data?.any((p) => p.id == objectRef) ?? false) { + _currentObjectRef = objectRef; + notifyListeners(); + return true; + } + + return false; // Object not found + } +} diff --git a/frontend/pshared/lib/pshared.dart b/frontend/pshared/lib/pshared.dart new file mode 100644 index 0000000..baa1a9d --- /dev/null +++ b/frontend/pshared/lib/pshared.dart @@ -0,0 +1,8 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'utils/http/requests.dart'; + +// TODO: Export any libraries intended for clients of this package. diff --git a/frontend/pshared/lib/service/account.dart b/frontend/pshared/lib/service/account.dart new file mode 100644 index 0000000..a51ccff --- /dev/null +++ b/frontend/pshared/lib/service/account.dart @@ -0,0 +1,61 @@ +import 'package:logging/logging.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pshared/api/requests/signup.dart'; +import 'package:pshared/api/responses/account.dart'; +import 'package:pshared/api/requests/change_password.dart'; +import 'package:pshared/data/mapper/account/account.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/files.dart'; +import 'package:pshared/service/services.dart'; +import 'package:pshared/utils/http/requests.dart'; + + +class AccountService { + static final _logger = Logger('service.account'); + static const String _objectType = Services.account; + + static Future login(String email, String password, String locale) async { + _logger.fine('Logging in'); + return AuthorizationService.login(_objectType, email, password, locale); + } + + static Future restore() async { + return AuthorizationService.restore(); + } + + static Future signup(SignupRequest request) async { + await getPOSTResponse(_objectType, 'signup', request.toJson()); + } + + static Future logout() async { + _logger.fine('Logging out'); + await AuthorizationService.logout(); + } + + static Future _getAccount(Future> future) async { + final response = await future; + return AccountResponse.fromJson(response).account.toDomain(); + } + + static Future update(Account account) async { + _logger.fine('Patching account ${account.id}'); + return _getAccount(AuthorizationService.getPUTResponse(_objectType, '', account.toDTO().toJson())); + } + + static Future changePassword(String oldPassword, String newPassword) async { + _logger.fine('Changing password'); + return _getAccount(AuthorizationService.getPATCHResponse( + _objectType, + 'password', + ChangePassword(oldPassword: oldPassword, newPassword: newPassword).toJson(), + )); + } + + static Future uploadAvatar(String id, XFile avatarFile) async { + _logger.fine('Uploading avatar'); + return FilesService.uploadImage(_objectType, id, avatarFile); + } +} diff --git a/frontend/pshared/lib/service/accounts/employees.dart b/frontend/pshared/lib/service/accounts/employees.dart new file mode 100644 index 0000000..b7cf7ba --- /dev/null +++ b/frontend/pshared/lib/service/accounts/employees.dart @@ -0,0 +1,35 @@ +import 'package:logging/logging.dart'; + +import 'package:pshared/api/errors/unauthorized.dart'; +import 'package:pshared/api/responses/employees.dart'; +import 'package:pshared/models/organization/employee.dart'; +import 'package:pshared/data/mapper/account/account.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/services.dart'; + + +class EmployeesService { + static final _logger = Logger('service.employees'); + static const String _objectType = Services.account; + + static Future> list(String organizationRef) async { + _logger.fine('Loading organization employees'); + return _getEmployees(AuthorizationService.getGETResponse(_objectType, '/list/$organizationRef')); + } + + + static Future> _getEmployees(Future> future) async { + try { + final responseJson = await future; + final response = EmployeesResponse.fromJson(responseJson); + final accounts = response.accounts.map((dto) => dto.toDomain()).toList(); + + if (accounts.isEmpty) throw ErrorUnauthorized(); + _logger.fine('Fetched ${accounts.length} account(s)'); + return accounts; + } catch (e, stackTrace) { + _logger.severe('Failed to fetch accounts', e, stackTrace); + rethrow; + } + } +} diff --git a/frontend/pshared/lib/service/authorization/service.dart b/frontend/pshared/lib/service/authorization/service.dart new file mode 100644 index 0000000..79ba68a --- /dev/null +++ b/frontend/pshared/lib/service/authorization/service.dart @@ -0,0 +1,89 @@ +import 'package:logging/logging.dart'; + +import 'package:pshared/api/errors/upload_failed.dart'; +import 'package:pshared/api/requests/login.dart'; +import 'package:pshared/api/responses/account.dart'; +import 'package:pshared/api/responses/login.dart'; +import 'package:pshared/config/constants.dart'; +import 'package:pshared/data/mapper/account/account.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/service/authorization/storage.dart'; +import 'package:pshared/service/authorization/token.dart'; +import 'package:pshared/service/device_id.dart'; +import 'package:pshared/utils/http/requests.dart' as httpr; + + +class AuthorizationService { + static final _logger = Logger('service.authorization'); + + static Future _updateAccessToken(AccountResponse response) async { + await AuthorizationStorage.updateToken(response.accessToken); + } + + static Future _updateTokens(LoginResponse response) async { + await _updateAccessToken(response); + return AuthorizationStorage.updateRefreshToken(response.refreshToken); + } + + static Future _completeLogin(Map response) async { + final LoginResponse lr = LoginResponse.fromJson(response); + await _updateTokens(lr); + return lr; + } + + static Future login(String service, String email, String password, String locale) async { + _logger.fine('Logging in $email with $locale locale'); + final deviceId = await DeviceIdManager.getDeviceId(); + final response = await httpr.getPOSTResponse( + service, + '/login', + LoginRequest( + login: email.toLowerCase(), + password: password, + locale: locale, + deviceId: deviceId, + clientId: Constants.clientId, + ).toJson()); + + return (await _completeLogin(response)).account.toDomain(); + } + + static Future restore() async { + return (await TokenService.rotateRefreshToken()).account.toDomain(); + } + + static Future logout() async { + return AuthorizationStorage.removeTokens(); + } + + static Future> _authenticatedRequest( + String service, + String url, + Future> Function(String, String, Map, {String? authToken}) requestType, + {Map? body}) async { + final accessToken = await TokenService.getAccessToken(); + return requestType(service, url, body ?? {}, authToken: accessToken); + } + + static Future> getPOSTResponse(String service, String url, Map body) async => _authenticatedRequest(service, url, httpr.getPOSTResponse, body: body); + + static Future> getGETResponse(String service, String url) async { + final accessToken = await TokenService.getAccessToken(); + return httpr.getGETResponse(service, url, authToken: accessToken); + } + + static Future> getPUTResponse(String service, String url, Map body) async => _authenticatedRequest(service, url, httpr.getPUTResponse, body: body); + + static Future> getPATCHResponse(String service, String url, Map body) async => _authenticatedRequest(service, url, httpr.getPATCHResponse, body: body); + + static Future> getDELETEResponse(String service, String url, Map body) async => _authenticatedRequest(service, url, httpr.getDELETEResponse, body: body); + + static Future getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List bytes) async { + final accessToken = await TokenService.getAccessToken(); + final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: accessToken); + if (res == null) { + throw ErrorUploadFailed(); + } + return res.url; + } +} diff --git a/frontend/pshared/lib/service/authorization/storage.dart b/frontend/pshared/lib/service/authorization/storage.dart new file mode 100644 index 0000000..bb49431 --- /dev/null +++ b/frontend/pshared/lib/service/authorization/storage.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:logging/logging.dart'; + +import 'package:pshared/api/errors/unauthorized.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/config/constants.dart'; +import 'package:pshared/service/secure_storage.dart'; + + +class AuthorizationStorage { + static final _logger = Logger('service.authorization.storage'); + + static Future _getTokenData(String tokenKey) async { + _logger.fine('Getting token data'); + final String? tokenJson = await SecureStorageService.get(tokenKey); + if (tokenJson == null || tokenJson.isEmpty) { + throw ErrorUnauthorized(); + } + return TokenData.fromJson(jsonDecode(tokenJson)); + } + + static Future getAccessToken() async { + _logger.fine('Getting access token'); + return _getTokenData(Constants.accessTokenStorageKey); + } + + static Future getRefreshToken() async { + _logger.fine('Getting refresh token'); + return _getTokenData(Constants.refreshTokenStorageKey); + } + + static Future updateToken(TokenData tokenData) async { + _logger.fine('Storing access token...'); + final tokenJson = jsonEncode(tokenData.toJson()); + await SecureStorageService.set(Constants.accessTokenStorageKey, tokenJson); + } + + static Future updateRefreshToken(TokenData tokenData) async { + _logger.fine('Storing refresh token...'); + final refreshTokenJson = jsonEncode(tokenData.toJson()); + await SecureStorageService.set(Constants.refreshTokenStorageKey, refreshTokenJson); + } + + static Future removeTokens() { + return Future.wait([ + SecureStorageService.delete(Constants.refreshTokenStorageKey), + SecureStorageService.delete(Constants.accessTokenStorageKey), + ]); + } +} + + diff --git a/frontend/pshared/lib/service/authorization/token.dart b/frontend/pshared/lib/service/authorization/token.dart new file mode 100644 index 0000000..0357e98 --- /dev/null +++ b/frontend/pshared/lib/service/authorization/token.dart @@ -0,0 +1,85 @@ + + +import 'package:logging/logging.dart'; + +import 'package:pshared/api/errors/unauthorized.dart'; +import 'package:pshared/api/requests/tokens/access_refresh.dart'; +import 'package:pshared/api/requests/tokens/refresh_rotate.dart'; +import 'package:pshared/api/responses/account.dart'; +import 'package:pshared/api/responses/login.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/config/constants.dart'; +import 'package:pshared/service/authorization/storage.dart'; +import 'package:pshared/service/device_id.dart'; +import 'package:pshared/service/services.dart'; +import 'package:pshared/utils/http/requests.dart'; + + +class TokenService { + static final _logger = Logger('service.authorization.token'); + static const String _objectType = Services.account; + + static bool _isTokenExpiringSoon(TokenData token, Duration duration) { + return token.expiration.isBefore(DateTime.now().add(duration)); + } + + static Future getAccessToken() async { + TokenData token = await AuthorizationStorage.getAccessToken(); + if (_isTokenExpiringSoon(token, const Duration(hours: 4))) { + token = (await _refreshAccessToken()).accessToken; + } + return token.token; + } + + static Future _updateTokens(LoginResponse response) async { + await AuthorizationStorage.updateToken(response.accessToken); + await AuthorizationStorage.updateRefreshToken(response.refreshToken); + } + + static Future _refreshAccessToken() async { + _logger.fine('Refreshing access token...'); + final deviceId = await DeviceIdManager.getDeviceId(); + final refresh = await AuthorizationStorage.getRefreshToken(); + + if (_isTokenExpiringSoon(refresh, const Duration(days: 7))) { + return await rotateRefreshToken(); + } + + final response = await getPOSTResponse( + _objectType, + '/refresh', + AccessTokenRefreshRequest( + deviceId: deviceId, + clientId: Constants.clientId, + token: refresh.token, + ).toJson(), + ); + + final accountResp = AccountResponse.fromJson(response); + await AuthorizationStorage.updateToken(accountResp.accessToken); + return accountResp; + } + + static Future rotateRefreshToken() async { + _logger.fine('Rotating refresh token...'); + final refresh = await AuthorizationStorage.getRefreshToken(); + + if (refresh.expiration.isBefore(DateTime.now())) throw ErrorUnauthorized(); + + final deviceId = await DeviceIdManager.getDeviceId(); + final response = await getPOSTResponse( + _objectType, + '/rotate', + RotateRefreshTokenRequest( + token: refresh.token, + clientId: Constants.clientId, + deviceId: deviceId, + ).toJson(), + ); + + final loginResponse = LoginResponse.fromJson(response); + await _updateTokens(loginResponse); + return loginResponse; + } + +} diff --git a/frontend/pshared/lib/service/device_id.dart b/frontend/pshared/lib/service/device_id.dart new file mode 100644 index 0000000..d6cf635 --- /dev/null +++ b/frontend/pshared/lib/service/device_id.dart @@ -0,0 +1,24 @@ +import 'package:uuid/uuid.dart'; + +import 'package:logging/logging.dart'; + +import 'package:pshared/config/web.dart'; +import 'package:pshared/service/secure_storage.dart'; + + +class DeviceIdManager { + static final _logger = Logger('service.device_id'); + + static final String _key = Constants.deviceIdStorageKey; + static Future getDeviceId() async { + String? deviceId = await SecureStorageService.get(_key); + + if (deviceId == null) { + _logger.fine('Device id is not set, generating new'); + deviceId = (const Uuid()).v4(); + await SecureStorageService.set(_key, deviceId); + } + + return deviceId; + } +} diff --git a/frontend/pshared/lib/service/files.dart b/frontend/pshared/lib/service/files.dart new file mode 100644 index 0000000..b4ed7b1 --- /dev/null +++ b/frontend/pshared/lib/service/files.dart @@ -0,0 +1,38 @@ +import 'dart:math'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/utils/image/conversion.dart'; +import 'package:pshared/utils/image/transformed.dart'; + + +String generateRandomLatinString(int length) { + const String chars = 'abcdefghijklmnopqrstuvwxyz'; + final Random random = Random.secure(); + return List.generate(length, (index) => chars[random.nextInt(chars.length)]).join(); +} + +class FilesService { + static Future uploadImage( + String objectType, + String? id, + XFile imageFile, { + Future Function(XFile) fileReader = defaultTransformImage, + }) async { + final objRef = id ?? generateRandomLatinString(16); + final image = await fileReader(imageFile); + final res = await AuthorizationService.getFileUploadResponseAuth( + objectType, + 'image/$objRef', + '$objRef.${image.imageType.split('/').last}', + 'image', + image.imageType, + image.bytes + ); + CachedNetworkImage.evictFromCache(res); + return res; + } +} diff --git a/frontend/pshared/lib/service/organization.dart b/frontend/pshared/lib/service/organization.dart new file mode 100644 index 0000000..d34b42f --- /dev/null +++ b/frontend/pshared/lib/service/organization.dart @@ -0,0 +1,63 @@ +import 'package:logging/logging.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pshared/api/errors/unauthorized.dart'; +import 'package:pshared/api/responses/organization.dart'; +import 'package:pshared/models/organization/organization.dart'; +import 'package:pshared/data/mapper/organization.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/files.dart'; +import 'package:pshared/service/services.dart'; +import 'package:pshared/utils/http/requests.dart'; + + +class OrganizationService { + static final _logger = Logger('service.organization'); + static const String _objectType = Services.organization; + + static Future> list() async { + _logger.fine('Loading all organizations'); + return _getOrganizations(AuthorizationService.getGETResponse(_objectType, '')); + } + + static Future load(String organizationRef) async { + _logger.fine('Loading organization $organizationRef'); + final orgs = await _getOrganizations(AuthorizationService.getGETResponse(_objectType, organizationRef)); + return orgs.first; + } + + static Future loadByInvitation(String invitationRef) async { + _logger.fine('Loading organization by invitation ref $invitationRef'); + final orgs = await _getOrganizations(getGETResponse(_objectType, 'invitation/$invitationRef')); + return orgs.first; + } + + static Future> update(Organization organization) async { + _logger.fine('Patching organization ${organization.id}'); + // Convert domain object to DTO, then to JSON + return _getOrganizations( + AuthorizationService.getPUTResponse(_objectType, '', organization.toDTO().toJson()) + ); + } + + static Future uploadLogo(String organizationRef, XFile logoFile) async { + _logger.fine('Uploading logo'); + return FilesService.uploadImage(_objectType, organizationRef, logoFile); + } + + static Future> _getOrganizations(Future> future) async { + try { + final responseJson = await future; + final response = OrganizationResponse.fromJson(responseJson); + final orgs = response.organizations.map((dto) => dto.toDomain()).toList(); + + if (orgs.isEmpty) throw ErrorUnauthorized(); + _logger.fine('Fetched ${orgs.length} organization(s)'); + return orgs; + } catch (e, stackTrace) { + _logger.severe('Failed to fetch organizations', e, stackTrace); + rethrow; + } + } +} diff --git a/frontend/pshared/lib/service/permissions.dart b/frontend/pshared/lib/service/permissions.dart new file mode 100644 index 0000000..7cbeefc --- /dev/null +++ b/frontend/pshared/lib/service/permissions.dart @@ -0,0 +1,79 @@ +import 'package:logging/logging.dart'; + +import 'package:pshared/api/requests/change_role.dart'; +import 'package:pshared/api/requests/permissions/change_policies.dart'; +import 'package:pshared/api/responses/policies.dart'; +import 'package:pshared/data/mapper/permissions/data/permissions.dart'; +import 'package:pshared/data/mapper/permissions/descriptions/description.dart'; +import 'package:pshared/models/permissions/access.dart'; +import 'package:pshared/models/permissions/data/policy.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/services.dart'; + + +class PermissionsService { + static final _logger = Logger('service.permissions'); + static const String _objectType = Services.permission; + + static Future load(String organizationRef) async { + _logger.fine('Loading permissions...'); + return _getPolicies(AuthorizationService.getGETResponse(_objectType, organizationRef)); + } + + static Future loadAll(String organizationRef) async { + _logger.fine('Loading permissions for all the users...'); + return _getPolicies(AuthorizationService.getGETResponse(_objectType, '/all/$organizationRef')); + } + + static Future changeRole(String organizationRef, ChangeRole request) async { + _logger.fine('Changing role for account ${request.accountRef} to role ${request.newRoleDescriptionRef}'); + await AuthorizationService.getPOSTResponse(_objectType, '/change_role/$organizationRef', request.toJson()); + } + + static Future deleteRoleDescription(String roleDescriptionRef) async { + _logger.fine('Deleting role $roleDescriptionRef...'); + await AuthorizationService.getDELETEResponse(_objectType, '/role/$roleDescriptionRef', {}); + } + + static Future createPolicies(List policies) async { + _logger.fine('Creating ${policies.length} policies...'); + await AuthorizationService.getPOSTResponse( + _objectType, + '/policies', + PoliciesChangeRequest.add(policies: policies).toJson(), + ); + } + + static Future deletePolicies(List policies) async { + _logger.fine('Deleting ${policies.length} policies...'); + await AuthorizationService.getDELETEResponse( + _objectType, + '/policies', + PoliciesChangeRequest.remove(policies: policies).toJson(), + ); + } + + static Future changePolicies(List add, List remove) async { + final common = add.toSet().intersection(remove.toSet()); + if (common.isNotEmpty) { + throw ArgumentError.value(common, 'add/remove', 'These policies are in both add and remove: ${common.toString()}'); + } + _logger.fine('Adding ${add.length} policies, removing ${remove.length} policies...'); + await AuthorizationService.getPUTResponse( + _objectType, + '/policies', + PoliciesChangeRequest.change(add: add, remove: remove).toJson(), + ); + } + + static Future _getPolicies(Future> future) async { + final resp = PoliciesResponse.fromJson(await future); + final res = UserAccess( + descriptions: resp.descriptions.toDomain(), + permissions: resp.permissions.toDomain(), + ); + _logger.fine('Loaded ${res.descriptions.roles.length} role descriptions, ${res.permissions.roles.length} role assignments, ${res.descriptions.policies.length} policy descriptions, ${res.permissions.policies.length} assigned policies, and ${res.permissions.permissions.length} assigned permissions'); + + return res; + } +} diff --git a/frontend/pshared/lib/service/pfe/login.dart b/frontend/pshared/lib/service/pfe/login.dart new file mode 100644 index 0000000..5f111aa --- /dev/null +++ b/frontend/pshared/lib/service/pfe/login.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'login.g.dart'; + + +@JsonSerializable() +class LoginRequest { + final String login; + final String password; + + const LoginRequest({required this.login, required this.password}); + + factory LoginRequest.fromJson(Map json) => _$LoginRequestFromJson(json); + Map toJson() => _$LoginRequestToJson(this); +} diff --git a/frontend/pshared/lib/service/pfe/service.dart b/frontend/pshared/lib/service/pfe/service.dart new file mode 100644 index 0000000..fc95f61 --- /dev/null +++ b/frontend/pshared/lib/service/pfe/service.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:logging/logging.dart'; + +import 'package:http/http.dart' as http; + +import 'package:pshared/service/pfe/login.dart'; + + +class PfeService { + static final _logger = Logger('service.pfe'); + + static Future login(String email, String password) async { + _logger.fine('Logging in'); + + try { + final res = await http.post( + Uri.parse('http://localhost:3000/api/v1/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(LoginRequest(login: email, password: password).toJson()), + ); + return res.toString(); + } catch (e) { + _logger.warning(e.toString()); + rethrow; + } + + } +} diff --git a/frontend/pshared/lib/service/pfe/services.dart b/frontend/pshared/lib/service/pfe/services.dart new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pshared/lib/service/secure_storage.dart b/frontend/pshared/lib/service/secure_storage.dart new file mode 100644 index 0000000..fa0f9e0 --- /dev/null +++ b/frontend/pshared/lib/service/secure_storage.dart @@ -0,0 +1,25 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SecureStorageService { + static Future get(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + static Future _setImp(SharedPreferences prefs, String key, String value) async { + await prefs.setString(key, value); + } + + static Future set(String key, String? value) async { + final prefs = await SharedPreferences.getInstance(); + if (value == null) { + return delete(key); + } + return _setImp(prefs, key, value); + } + + static Future delete(String key) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } +} diff --git a/frontend/pshared/lib/service/services.dart b/frontend/pshared/lib/service/services.dart new file mode 100644 index 0000000..efeb745 --- /dev/null +++ b/frontend/pshared/lib/service/services.dart @@ -0,0 +1,32 @@ +class Services { + static const String account = 'accounts'; + static const String authorization = 'authorization'; + static const String comments = 'comments'; + static const String device = 'device'; + static const String invitations = 'invitations'; + static const String organization = 'organizations'; + static const String permission = 'permissions'; + static const String project = 'projects'; + static const String pgroup = 'priority_groups'; + static const String priorities = 'priorities'; + static const String reactions = 'reactions'; + static const String storage = 'storage'; + static const String taskStatus = 'statuses'; + static const String tasks = 'tasks'; + + static const String amplitude = 'amplitude'; + static const String automations = 'automation'; + static const String changes = 'changes'; + static const String clients = 'clients'; + static const String invoices = 'invoices'; + static const String logo = 'logo'; + static const String notifications = 'notifications'; + static const String policies = 'policies'; + static const String properties = 'properties'; + static const String refreshTokens = 'refresh_tokens'; + static const String roles = 'roles'; + static const String steps = 'steps'; + static const String teams = 'teams'; + static const String workflows = 'workflows'; + static const String workspaces = 'workspaces'; +} diff --git a/frontend/pshared/lib/service/template.dart b/frontend/pshared/lib/service/template.dart new file mode 100644 index 0000000..d61a2aa --- /dev/null +++ b/frontend/pshared/lib/service/template.dart @@ -0,0 +1,66 @@ +import 'package:logging/logging.dart'; + +import 'package:pshared/service/authorization/service.dart'; + + +class BasicService { + final String _objectType; + final Logger _logger; + final List Function(Map json) fromJson; + + Logger get logger => _logger; + + BasicService({ + required String objectType, + required this.fromJson, + }) : _objectType = objectType, _logger = Logger('service.$objectType'); + + Future> list(String organizationRef, String parentRef) async { + _logger.fine('Loading all objects'); + return _getObjects( + AuthorizationService.getGETResponse(_objectType, '/list/$organizationRef/$parentRef'), + ); + } + + Future get(String objectRef) async { + _logger.fine('Loading object $objectRef'); + final objects = await _getObjects( + AuthorizationService.getGETResponse(_objectType, '/$objectRef'), + ); + return objects.first; + } + + Future> create(String organizationRef, Map request) async { + _logger.fine('Creating new object...'); + return _getObjects( + AuthorizationService.getPOSTResponse(_objectType, '/$organizationRef', request), + ); + } + + Future> update(Map request) async { + _logger.fine('Patching object...'); + return _getObjects( + AuthorizationService.getPUTResponse(_objectType, '/', request, + ), + ); + } + + Future> delete(String objecRef) async { + _logger.fine('Deleting object $objecRef'); + return _getObjects( + AuthorizationService.getDELETEResponse(_objectType, '/$objecRef', {}), + ); + } + + Future> _getObjects(Future> future) async { + try { + final responseJson = await future; + final objects = fromJson(responseJson); + _logger.fine('Fetched ${objects.length} object(s)'); + return objects; + } catch (e, stackTrace) { + _logger.severe('Failed to fetch objects', e, stackTrace); + rethrow; + } + } +} diff --git a/frontend/pshared/lib/utils/clipboard.dart b/frontend/pshared/lib/utils/clipboard.dart new file mode 100644 index 0000000..9b71a63 --- /dev/null +++ b/frontend/pshared/lib/utils/clipboard.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:pshared/utils/snackbar.dart'; + + +Future copyToClipboard(BuildContext context, String text, String hint, {int delaySeconds = 3}) async { + final res = Clipboard.setData(ClipboardData(text: text)); + notifyUser(context, hint, delaySeconds: delaySeconds); + return res; +} \ No newline at end of file diff --git a/frontend/pshared/lib/utils/currency.dart b/frontend/pshared/lib/utils/currency.dart new file mode 100644 index 0000000..af50a31 --- /dev/null +++ b/frontend/pshared/lib/utils/currency.dart @@ -0,0 +1,22 @@ +String currencyCodeToSymbol(String currencyCode) { + switch (currencyCode) { + case 'USD': + return '\$'; + case 'PLN': + return 'zł'; + case 'EUR': + return '€'; + case 'GBP': + return '£'; + case 'HUF': + return 'Ft'; + case 'RUB': + return '₽'; + default: + return currencyCode; + } +} + +String currencyToString(String currencyCode, double amount) { + return '${currencyCodeToSymbol(currencyCode)}\u00A0${amount.toStringAsFixed(2)}'; +} \ No newline at end of file diff --git a/frontend/pshared/lib/utils/datetime_serializer.dart b/frontend/pshared/lib/utils/datetime_serializer.dart new file mode 100644 index 0000000..6cc18a2 --- /dev/null +++ b/frontend/pshared/lib/utils/datetime_serializer.dart @@ -0,0 +1,9 @@ +extension DateTimeSerializer on DateTime { + static String toBackendString(DateTime dt) { + return dt.toUtc().toIso8601String(); + } + + static DateTime fromBackendString(String dateStr) { + return DateTime.parse(dateStr); + } +} diff --git a/frontend/pshared/lib/utils/flagged_locale.dart b/frontend/pshared/lib/utils/flagged_locale.dart new file mode 100644 index 0000000..e307953 --- /dev/null +++ b/frontend/pshared/lib/utils/flagged_locale.dart @@ -0,0 +1,91 @@ +import 'package:intl/intl.dart'; + +import 'package:flutter/material.dart'; + +import 'package:jovial_svg/jovial_svg.dart'; + +import 'package:country_flags/country_flags.dart'; + + +String _locale2Flag(Locale l) { + if (l.languageCode == 'en') { + return 'gb'; + } + if (l.languageCode == 'uk') { + return 'ua'; + } + if (l.languageCode == 'el') { + return 'gr'; + } + return l.languageCode; +} + +final Map localeNames = { + 'ca': 'Català', + 'en': 'English', + 'es': 'Español', + 'fr': 'Français', + 'de': 'Deutsch', + 'uk': 'Українська', + 'el': 'Ελληνικά', + 'ru': 'Русский', + 'pt': 'Português', + 'pl': 'Polski', + 'it': 'Italiano', + 'nl': 'Nederlands', +}; + +class _CatalanFlag extends StatelessWidget { + final double? width; + final double? height; + final BoxFit? fit; + + _CatalanFlag({ + required this.width, + required this.height, + required this.fit, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: ScalableImageWidget.fromSISource( + key: const Key('svgFlagCa'), + si: ScalableImageSource.fromSI( + DefaultAssetBundle.of(context), + 'packages/pshared/assets/flag_of_catalonia.si', + ), + fit: BoxFit.contain, + ), + ); + } +} + +Widget getCountryFlag(Locale locale, {double? height = 24, double? width = 35}) { + return locale.languageCode.toLowerCase() == 'ca' + ? _CatalanFlag(width: width, height: height, fit: BoxFit.contain) + : CountryFlag.fromCountryCode( + _locale2Flag(locale), + height: height, + width: width, + shape: Rectangle(), + ); +} + +String getLocaleName(Locale locale) { + return localeNames[locale.languageCode] ?? Intl.canonicalizedLocale(locale.toString()).toUpperCase(); +} + +Widget getLocaleNameWidget(Locale locale) { + return Text(getLocaleName(locale), overflow: TextOverflow.ellipsis); +} + +Widget getFlaggedLocale(Locale locale) { + return ListTile( + leading: getCountryFlag(locale), + title: getLocaleNameWidget(locale), + ); +} + diff --git a/frontend/pshared/lib/utils/http/client.dart b/frontend/pshared/lib/utils/http/client.dart new file mode 100644 index 0000000..c9d5e0d --- /dev/null +++ b/frontend/pshared/lib/utils/http/client.dart @@ -0,0 +1,2 @@ +export 'client/io.dart' + if (dart.library.html) 'http_client/web.dart'; \ No newline at end of file diff --git a/frontend/pshared/lib/utils/http/client/io.dart b/frontend/pshared/lib/utils/http/client/io.dart new file mode 100644 index 0000000..9f66e3f --- /dev/null +++ b/frontend/pshared/lib/utils/http/client/io.dart @@ -0,0 +1,55 @@ +import 'dart:io' as io show HttpClient, HttpHeaders; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + + +const _sessionCookie = 'session_id'; + +@override +http.Client buildHttpClient() => _SessionClient(IOClient(io.HttpClient())); + +class _SessionClient extends http.BaseClient { + final http.Client _inner; + String? _sessionId; + + _SessionClient(this._inner); + + @override + Future send(http.BaseRequest request) async { + if (_sessionId != null) { + request.headers[io.HttpHeaders.cookieHeader] = '$_sessionCookie=$_sessionId'; + } + + request.followRedirects = false; + request.maxRedirects = 0; + + http.StreamedResponse response = await _inner.send(request); + + _captureCookie(response.headers[io.HttpHeaders.setCookieHeader]); + + while (response.isRedirect) { + final location = response.headers['location']; + if (location == null) break; + + final redirected = http.Request(request.method, Uri.parse(location)) + ..headers.addAll(request.headers) + ..bodyBytes = await response.stream.toBytes() + ..followRedirects = false + ..maxRedirects = 0; + + response = await _inner.send(redirected); + _captureCookie(response.headers[io.HttpHeaders.setCookieHeader]); + } + + return response; + } + + void _captureCookie(String? setCookieHeader) { + if (setCookieHeader == null) return; + final match = RegExp('$_sessionCookie=([^;]+)', + caseSensitive: false) + .firstMatch(setCookieHeader); + if (match != null) _sessionId = match.group(1); + } +} diff --git a/frontend/pshared/lib/utils/http/client/stub.dart b/frontend/pshared/lib/utils/http/client/stub.dart new file mode 100644 index 0000000..c669fb7 --- /dev/null +++ b/frontend/pshared/lib/utils/http/client/stub.dart @@ -0,0 +1,4 @@ +import 'package:http/http.dart'; + + +Client buildHttpClient() => throw UnsupportedError('buildHttpClient() was called without a proper platform implementation.'); diff --git a/frontend/pshared/lib/utils/http/client/web.dart b/frontend/pshared/lib/utils/http/client/web.dart new file mode 100644 index 0000000..3094e38 --- /dev/null +++ b/frontend/pshared/lib/utils/http/client/web.dart @@ -0,0 +1,9 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; + +@override +http.Client buildHttpClient() { + final bc = BrowserClient(); + bc.withCredentials = true; + return bc; +} diff --git a/frontend/pshared/lib/utils/http/requests.dart b/frontend/pshared/lib/utils/http/requests.dart new file mode 100644 index 0000000..7902d64 --- /dev/null +++ b/frontend/pshared/lib/utils/http/requests.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:http_parser/http_parser.dart'; + +import 'package:pshared/api/responses/file_uploaded.dart'; +import 'package:pshared/api/responses/message.dart'; +import 'package:pshared/api/responses/error/connectivity.dart'; +import 'package:pshared/api/responses/error/server.dart'; +import 'package:pshared/config/constants.dart'; + + +Uri _uri(String service, String url) { + // Ensure the base URL ends with a slash + final normalizedBaseUrl = Constants.apiUrl.endsWith('/') + ? Constants.apiUrl + : '${Constants.apiUrl}/'; + + // Remove leading slash from service, if any + final normalizedService = service.startsWith('/') ? service.substring(1) : service; + + // Remove leading slash from url, if any + final normalizedUrl = url.startsWith('/') ? url.substring(1) : url; + + // Only append a trailing slash to the service + // if the URL is non-empty + final serviceWithOptionalSlash = normalizedUrl.isNotEmpty ? '$normalizedService/' : normalizedService; + + return Uri.parse(normalizedBaseUrl).resolve(serviceWithOptionalSlash).resolve(normalizedUrl); +} + + + +Map _authHeader(Map headers, String? authToken) { + if (authToken != null && authToken.isNotEmpty) { + headers['Authorization'] = 'Bearer $authToken'; + } + return headers; +} + +Map _headers({String? authToken}) { + final headers = {'Content-Type': 'application/json'}; + return _authHeader(headers, authToken); +} + +Future postRequest(String service, String url, Map body, {String? authToken}) async { + final response = await http.post(_uri(service, url), + headers: _headers(authToken: authToken), + body: json.encode(body), + ); + return response; +} + +Future getRequest(String service, String url, {String? authToken}) async { + final response = await http.get(_uri(service, url), + headers: _headers(authToken: authToken), + ); + return response; +} + +Future putRequest(String service, String url, Map body, {String? authToken}) async { + final response = await http.put(_uri(service, url), + headers: _headers(authToken: authToken), + body: json.encode(body), + ); + return response; +} + +Future patchRequest(String service, String url, Map body, {String? authToken}) async { + final response = await http.patch(_uri(service, url), + headers: _headers(authToken: authToken), + body: json.encode(body), + ); + return response; +} + +Future deleteRequest(String service, String url, Map body, {String? authToken}) async { + final response = await http.delete(_uri(service, url), + headers: _headers(authToken: authToken), + body: json.encode(body), + ); + return response; +} + +Future _fileUploadRequest(String service, String url, String fileName, String fileType, String mediaType, List bytes, {String? authToken}) async { + var request = http.MultipartRequest('POST', _uri(service, url)); + + var multipartFile = http.MultipartFile.fromBytes( + fileType, + bytes, + contentType: MediaType.parse(mediaType), + filename: fileName, + ); + + request.files.add(multipartFile); + + if (authToken != null && authToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer $authToken'; + } + + return request.send(); +} + +void _throwConnectivityError(http.Response response, Object e) { + throw ConnectivityError( + code: response.statusCode, + message: e is FormatException + ? 'Invalid response format. error: ${e.toString()}' + : 'Unknown error occurred, error: ${e.toString()}', + ); +} + +Future> _handleResponse(Future r) async { + late http.Response response; + try { + response = await r; + } catch(e) { + throw ConnectivityError(message: e.toString()); + } + + late HTTPMessage message; + try { + message = HTTPMessage.fromJson(json.decode(response.body)); + } catch(e) { + _throwConnectivityError(response, e); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + late ErrorResponse error; + try { + error = ErrorResponse.fromJson(message.data); + } catch(e) { + _throwConnectivityError(response, e); + } + throw error; + } + + return message.data; +} + +Future> getPOSTResponse(String service, String url, Map body, {String? authToken}) async { + return _handleResponse(postRequest(service, url, body, authToken: authToken)); +} + +Future> getGETResponse(String service, String url, {String? authToken}) async { + return _handleResponse(getRequest(service, url, authToken: authToken)); +} + +Future> getPUTResponse(String service, String url, Map body, {String? authToken}) async { + return _handleResponse(putRequest(service, url, body, authToken: authToken)); +} + +Future> getPATCHResponse(String service, String url, Map body, {String? authToken}) async { + return _handleResponse(patchRequest(service, url, body, authToken: authToken)); +} + +Future> getDELETEResponse(String service, String url, Map body, {String? authToken}) async { +return _handleResponse(deleteRequest(service, url, body, authToken: authToken)); +} + +Future getFileUploadResponse(String service, String url, String fileName, String fileType, String mediaType, List bytes, {String? authToken}) async { + final streamedResponse = await _fileUploadRequest(service, url, fileName, fileType, mediaType, bytes, authToken: authToken); + return FileUploaded.fromJson(await _handleResponse(http.Response.fromStream(streamedResponse))); +} diff --git a/frontend/pshared/lib/utils/image/conversion.dart b/frontend/pshared/lib/utils/image/conversion.dart new file mode 100644 index 0000000..09da826 --- /dev/null +++ b/frontend/pshared/lib/utils/image/conversion.dart @@ -0,0 +1,45 @@ +import 'package:image/image.dart' as img; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pshared/api/errors/failed_to_read_image.dart'; +import 'package:pshared/config/constants.dart'; +import 'package:pshared/utils/image/transformed.dart'; + + +bool _imageTooBig(img.Image image, int maxDimension) { + return image.width > maxDimension && image.height > maxDimension; +} + +bool _imageTooSmall(img.Image image, int maxDimension) { + return image.width < maxDimension && image.height < maxDimension; +} + +TransformedImage _getImageBytes(img.Image image, int maxDimension) { + List imageBytes; + + if (_imageTooBig(image, maxDimension) || _imageTooSmall(image, maxDimension)) { + final double scale = image.width < image.height + ? maxDimension / image.width + : maxDimension / image.height; + + int newWidth = (image.width * scale).toInt(); + int newHeight = (image.height * scale).toInt(); + + final img.Image resizedImage = img.copyResize(image, width: newWidth, height: newHeight); + image = resizedImage; + } + + imageBytes = img.encodePng(image); + return TransformedImage(imageBytes, 'image/png'); +} + +Future defaultTransformImage(XFile file, {int? maxDimension}) async { + maxDimension = maxDimension ?? Constants.defaultDimensionLength; + img.Image? image = img.decodeImage(await file.readAsBytes()); + if (image == null) { + throw ErrorFailedToReadImage(); + } + + return _getImageBytes(image, maxDimension); +} diff --git a/frontend/pshared/lib/utils/image/transformed.dart b/frontend/pshared/lib/utils/image/transformed.dart new file mode 100644 index 0000000..5fde24f --- /dev/null +++ b/frontend/pshared/lib/utils/image/transformed.dart @@ -0,0 +1,6 @@ +class TransformedImage { + final List bytes; + final String imageType; + + TransformedImage(this.bytes, this.imageType); +} diff --git a/frontend/pshared/lib/utils/localization.dart b/frontend/pshared/lib/utils/localization.dart new file mode 100644 index 0000000..953df05 --- /dev/null +++ b/frontend/pshared/lib/utils/localization.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/config/constants.dart'; +import 'package:pshared/models/settings/localizations.dart' as loc; +import 'package:pshared/provider/locale.dart'; + + +String currentLocale(BuildContext context) => Provider.of(context, listen: false).locale.languageCode; + +String _localizedString( + BuildContext context, + String Function(loc.Localizations locs, String locale, {String? fallback}) localizationFunction, + loc.Localizations locs, { + String? fallback, +}) => localizationFunction(locs, currentLocale(context), fallback: fallback); + +String _anyLocalizedString( + BuildContext context, + String Function(loc.Localizations locs, String locale, {String? fallback}) localizationFunction, + loc.Localizations locs, { + String? fallback, +}) => localizationFunction( + locs, + currentLocale(context), + fallback: localizationFunction( + locs, + Constants.defaultLocale.languageCode, + fallback: locs.isEmpty + ? fallback + : localizationFunction( + locs, + locs.keys.first, + fallback: fallback, + ), + ), +); + +String name(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.name, locs, fallback: fallback); +} + +String anyName(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.name, locs, fallback: fallback); +} + +String hint(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.hint, locs, fallback: fallback); +} + +String anyHint(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.hint, locs, fallback: fallback); +} + +String link(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.link, locs, fallback: fallback); +} + +String anyLink(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.link, locs, fallback: fallback); +} + +String error(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.error, locs, fallback: fallback); +} + +String anyError(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.error, locs, fallback: fallback); +} + +String address(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.address, locs, fallback: fallback); +} + +String anyAddress(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.address, locs, fallback: fallback); +} + +String details(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.details, locs, fallback: fallback); +} + +String anyDetails(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.details, locs, fallback: fallback); +} + +String route(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _localizedString(context, loc.Localization.route, locs, fallback: fallback); +} + +String anyRoute(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.route, locs, fallback: fallback); +} + +String anyLocationName(BuildContext context, loc.Localizations locs, {String? fallback}) { + return _anyLocalizedString(context, loc.Localization.locationName, locs, fallback: fallback); +} + +String translate(BuildContext context, loc.Localizations locs, String key, {String? fallback}) { + return loc.Localization.translate(locs, currentLocale(context), key, fallback: fallback); +} + + +String anyTranslateFromLang(loc.Localizations locs, String lang, String key, {String? fallback}) => + loc.Localization.translate(locs, lang, key, + fallback: loc.Localization.translate(locs, Constants.defaultLocale.languageCode, key, + fallback: loc.Localization.translate(locs, locs.keys.first, key, fallback: fallback), + ), + ); + +String anyTranslate(BuildContext context, loc.Localizations locs, String key, {String? fallback}) => + anyTranslateFromLang(locs, currentLocale(context), key, fallback: fallback); + +String dateToLocalFormat(BuildContext context, DateTime dateTime) { + String locale = Provider.of(context, listen: false).locale.toString(); + final dateFormat = DateFormat('E, ', locale).add_yMd(); + return dateFormat.format(dateTime); +} + +String dateTimeToLocalFormat(BuildContext context, DateTime dateTime) { + String locale = Provider.of(context, listen: false).locale.toString(); + final dateFormat = DateFormat('E, ', locale).add_yMd().add_jm(); + return dateFormat.format(dateTime); +} + +String dateTimeToLocalFormatAuto(BuildContext context, DateTime dateTime, bool? dateOnly) { + return (dateOnly ?? false) ? dateToLocalFormat(context, dateTime) : dateTimeToLocalFormat(context, dateTime); +} + +String dateTimeOrNullToLocalFormat(BuildContext context, DateTime? dateTime, {String? fallback}) { + if (dateTime == null) return fallback ?? ''; + return dateTimeToLocalFormat(context, dateTime); +} diff --git a/frontend/pshared/lib/utils/name_initials.dart b/frontend/pshared/lib/utils/name_initials.dart new file mode 100644 index 0000000..cc92e21 --- /dev/null +++ b/frontend/pshared/lib/utils/name_initials.dart @@ -0,0 +1,17 @@ +class NameInitials { + + static const unknown = '?'; + +} + + +String getNameInitials(String name) { + if (name.isEmpty) return NameInitials.unknown; + // Split the name by whitespace. + final words = name.trim().split(RegExp(r'\s+')); + if (words.isEmpty) return NameInitials.unknown; + // If there's only one word, return its first letter. + if (words.length == 1) return words.first[0].toUpperCase(); + // Otherwise, use the first letter of the first and last words. + return (words.first[0] + words.last[0]).toUpperCase(); +} \ No newline at end of file diff --git a/frontend/pshared/lib/utils/share.dart b/frontend/pshared/lib/utils/share.dart new file mode 100644 index 0000000..f269fe8 --- /dev/null +++ b/frontend/pshared/lib/utils/share.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + + +Rect? sharePositionOrigin(BuildContext context) { + final RenderBox box = context.findRenderObject() as RenderBox; + return box.localToGlobal(Offset.zero) & box.size; +} \ No newline at end of file diff --git a/frontend/pshared/lib/utils/snackbar.dart b/frontend/pshared/lib/utils/snackbar.dart new file mode 100644 index 0000000..ce2dd3b --- /dev/null +++ b/frontend/pshared/lib/utils/snackbar.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + + +ScaffoldFeatureController notifyUserX(ScaffoldMessengerState sm, String message, { int delaySeconds = 3 }) +{ + return sm.showSnackBar( + SnackBar( + content: Text(message), + duration: Duration(seconds: delaySeconds), + ), + ); +} + +ScaffoldFeatureController notifyUser(BuildContext context, String message, { int delaySeconds = 3 }) { + return notifyUserX(ScaffoldMessenger.of(context), message, delaySeconds: delaySeconds); +} + +Future> postNotifyUser( + BuildContext context, String message, {int delaySeconds = 3}) { + + final completer = Completer>(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final controller = notifyUser(context, message, delaySeconds: delaySeconds); + completer.complete(controller); + }); + + return completer.future; +} diff --git a/frontend/pshared/lib/widgets/locale.dart b/frontend/pshared/lib/widgets/locale.dart new file mode 100644 index 0000000..43ef50d --- /dev/null +++ b/frontend/pshared/lib/widgets/locale.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/locale.dart'; +import 'package:pshared/utils/flagged_locale.dart'; + + +class LocaleChangerDropdown extends StatelessWidget { + final List availableLocales; + final bool lettersMode; + const LocaleChangerDropdown({ + super.key, + required this.availableLocales, + this.lettersMode = false, + }); + + Widget textLabel(BuildContext context, Locale locale) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(26), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.language), + Container(width: 4.0), + Text( + locale.languageCode.toUpperCase(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + Icon(Icons.arrow_drop_down, color: Theme.of(context).iconTheme.color), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (availableLocales.length <= 1) { + // If only one locale is available, do not show the button. + return const SizedBox.shrink(); + } + + var localeProvider = Provider.of(context); + + return PopupMenuButton( + icon: lettersMode ? textLabel(context, localeProvider.locale) : getCountryFlag(localeProvider.locale), + onSelected: localeProvider.setLocale, + itemBuilder: (BuildContext context) { + return availableLocales.map((Locale locale) { + return PopupMenuItem( + value: locale, + child: lettersMode ? getLocaleNameWidget(locale) : getFlaggedLocale(locale), + ); + }).toList(); + }, + ); + } +} diff --git a/frontend/pshared/lib/widgets/template.dart b/frontend/pshared/lib/widgets/template.dart new file mode 100644 index 0000000..5692f67 --- /dev/null +++ b/frontend/pshared/lib/widgets/template.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/template.dart'; + + +class ResourceContainer extends StatelessWidget { + final Widget Function(BuildContext context, T provider) builder; + final Widget? loading; + final Widget? error; + final Widget? empty; + + const ResourceContainer({ + required this.builder, + this.loading, + this.error, + this.empty, + }); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { + if (provider.isLoading) return loading ?? Center(child: CircularProgressIndicator()); + if (provider.error != null) return error ?? Text('Error while loading data. Try again'); //TODO: need to implement localizations and add more details to the error + if (provider.isEmpty) return empty ?? Text('Empty data'); //TODO: need to implement localizations too + return builder(context, provider); + }); +} diff --git a/frontend/pshared/pubspec.yaml b/frontend/pshared/pubspec.yaml new file mode 100644 index 0000000..4995137 --- /dev/null +++ b/frontend/pshared/pubspec.yaml @@ -0,0 +1,43 @@ +name: pshared +description: A starting point for Dart libraries or applications. +version: 1.0.0 + +environment: + sdk: ^3.1.5 + +# Add regular dependencies here. +dependencies: + json_annotation: ^4.9.0 + http: ^1.1.0 + provider: ^6.0.5 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + country_flags: ^3.0.0 + font_awesome_flutter: ^10.5.0 + flutter_svg: ^2.0.9 + http_parser: ^4.0.2 + collection: ^1.18.0 + cached_network_image: ^3.3.0 + jovial_svg: ^1.1.23 + logging: ^1.3.0 + share_plus: ^11.0.0 + uuid: ^4.5.1 + image: ^4.5.4 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_lints: ^6.0.0 + lints: ^6.0.0 + test: ^1.21.0 + build_runner: ^2.4.6 + json_serializable: ^6.7.1 + +flutter: + generate: true + + uses-material-design: true + assets: + - assets/flag_of_catalonia.si \ No newline at end of file diff --git a/frontend/pshared/test/test.dart b/frontend/pshared/test/test.dart new file mode 100644 index 0000000..4168ba8 --- /dev/null +++ b/frontend/pshared/test/test.dart @@ -0,0 +1,6 @@ +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + }); +} diff --git a/frontend/pweb/.gitignore b/frontend/pweb/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/frontend/pweb/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/frontend/pweb/.metadata b/frontend/pweb/.metadata new file mode 100644 index 0000000..b95fa4d --- /dev/null +++ b/frontend/pweb/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/pweb/README.md b/frontend/pweb/README.md new file mode 100644 index 0000000..c43db62 --- /dev/null +++ b/frontend/pweb/README.md @@ -0,0 +1,16 @@ +# web + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/frontend/pweb/analysis_options.yaml b/frontend/pweb/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/frontend/pweb/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/pweb/android/.gitignore b/frontend/pweb/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/frontend/pweb/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/frontend/pweb/android/app/build.gradle.kts b/frontend/pweb/android/app/build.gradle.kts new file mode 100644 index 0000000..49142dd --- /dev/null +++ b/frontend/pweb/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.web" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.web" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/frontend/pweb/android/app/src/debug/AndroidManifest.xml b/frontend/pweb/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/pweb/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/pweb/android/app/src/main/AndroidManifest.xml b/frontend/pweb/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1160736 --- /dev/null +++ b/frontend/pweb/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/android/app/src/main/kotlin/com/example/web/MainActivity.kt b/frontend/pweb/android/app/src/main/kotlin/com/example/web/MainActivity.kt new file mode 100644 index 0000000..b34bbbb --- /dev/null +++ b/frontend/pweb/android/app/src/main/kotlin/com/example/web/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.web + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/frontend/pweb/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/pweb/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/frontend/pweb/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/frontend/pweb/android/app/src/main/res/drawable/launch_background.xml b/frontend/pweb/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/frontend/pweb/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/frontend/pweb/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/pweb/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..23fa0d8 Binary files /dev/null and b/frontend/pweb/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/pweb/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/pweb/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..81b7f22 Binary files /dev/null and b/frontend/pweb/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/pweb/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/pweb/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e6d8690 Binary files /dev/null and b/frontend/pweb/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/pweb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/pweb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7059c6d Binary files /dev/null and b/frontend/pweb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/pweb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/pweb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9d7cfe6 Binary files /dev/null and b/frontend/pweb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/pweb/android/app/src/main/res/values-night/styles.xml b/frontend/pweb/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/frontend/pweb/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/frontend/pweb/android/app/src/main/res/values/styles.xml b/frontend/pweb/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/frontend/pweb/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/frontend/pweb/android/app/src/profile/AndroidManifest.xml b/frontend/pweb/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/frontend/pweb/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/pweb/android/build.gradle.kts b/frontend/pweb/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/frontend/pweb/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/frontend/pweb/android/gradle.properties b/frontend/pweb/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/frontend/pweb/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/frontend/pweb/android/gradle/wrapper/gradle-wrapper.properties b/frontend/pweb/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/frontend/pweb/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/frontend/pweb/android/settings.gradle.kts b/frontend/pweb/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/frontend/pweb/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/frontend/pweb/entrypoint.sh b/frontend/pweb/entrypoint.sh new file mode 100755 index 0000000..542be68 --- /dev/null +++ b/frontend/pweb/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +replace_env_var() { + local var_name=$1 + local placeholder="%%$var_name%%" + local value=$(eval echo \$$var_name) + # inject value to the index.html + sed -i "s|$placeholder|$value|g" /usr/share/pweb/index.html +} + +echo "Starting Container" + +replace_env_var "WS_PROTOCOL" +replace_env_var "WS_ENDPOINT" +replace_env_var "API_PROTOCOL" +replace_env_var "SERVICE_HOST" +replace_env_var "API_ENDPOINT" +replace_env_var "AMPLITUDE_SECRET" +replace_env_var "DEFAULT_LOCALE" +replace_env_var "DEFAULT_CURRENCY" + +echo "Passing by launch command" +# Execute the passed command (e.g., starting Nginx) +# exec "$@" +exec nginx -g 'daemon off;' \ No newline at end of file diff --git a/frontend/pweb/ios/.gitignore b/frontend/pweb/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/frontend/pweb/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/frontend/pweb/ios/Flutter/AppFrameworkInfo.plist b/frontend/pweb/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/frontend/pweb/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/frontend/pweb/ios/Flutter/Debug.xcconfig b/frontend/pweb/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/frontend/pweb/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/frontend/pweb/ios/Flutter/Release.xcconfig b/frontend/pweb/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/frontend/pweb/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/frontend/pweb/ios/Podfile b/frontend/pweb/ios/Podfile new file mode 100644 index 0000000..e549ee2 --- /dev/null +++ b/frontend/pweb/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/frontend/pweb/ios/Podfile.lock b/frontend/pweb/ios/Podfile.lock new file mode 100644 index 0000000..f9cf8e0 --- /dev/null +++ b/frontend/pweb/ios/Podfile.lock @@ -0,0 +1,61 @@ +PODS: + - Flutter (1.0.0) + - flutter_timezone (0.0.1): + - Flutter + - image_picker_ios (0.0.1): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 + +COCOAPODS: 1.16.2 diff --git a/frontend/pweb/ios/Runner.xcodeproj/project.pbxproj b/frontend/pweb/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7fec881 --- /dev/null +++ b/frontend/pweb/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2C56D73CE91635539A9A15DA /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6B53E22FD6A63D40B1C3D0B8 /* Pods_RunnerTests.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7FDB1F7A73965C7C67C9A58A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3191231068A580A1B7EB0FC1 /* Pods_Runner.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 11E3F4B5E7F65D8227C50E6E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3191231068A580A1B7EB0FC1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 61B869663D462553198E2AC1 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6B53E22FD6A63D40B1C3D0B8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 995403D706740B49D3A8D16E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + A6678C3B3551DF40750DA02B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + A6E3941EC81B9DAA2584BE2B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B5C149D70208E226E8B02D31 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8E6A3AB46FFEEA916FAA437C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C56D73CE91635539A9A15DA /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7FDB1F7A73965C7C67C9A58A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + F8666F8B24C7533EE74CB803 /* Pods */, + C315FB501BE68ED01CE7B90E /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + C315FB501BE68ED01CE7B90E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3191231068A580A1B7EB0FC1 /* Pods_Runner.framework */, + 6B53E22FD6A63D40B1C3D0B8 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + F8666F8B24C7533EE74CB803 /* Pods */ = { + isa = PBXGroup; + children = ( + A6E3941EC81B9DAA2584BE2B /* Pods-Runner.debug.xcconfig */, + 995403D706740B49D3A8D16E /* Pods-Runner.release.xcconfig */, + 11E3F4B5E7F65D8227C50E6E /* Pods-Runner.profile.xcconfig */, + A6678C3B3551DF40750DA02B /* Pods-RunnerTests.debug.xcconfig */, + 61B869663D462553198E2AC1 /* Pods-RunnerTests.release.xcconfig */, + B5C149D70208E226E8B02D31 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + FB52EAF123C25402F515699A /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 8E6A3AB46FFEEA916FAA437C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1D3F480F2DA3F801E5B09957 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + CA85DA6319399F521D13FFCD /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1D3F480F2DA3F801E5B09957 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + CA85DA6319399F521D13FFCD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + FB52EAF123C25402F515699A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88GA9VDY6Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.web; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A6678C3B3551DF40750DA02B /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 61B869663D462553198E2AC1 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B5C149D70208E226E8B02D31 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88GA9VDY6Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.web; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88GA9VDY6Q; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.web; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/pweb/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/pweb/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/pweb/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/frontend/pweb/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/pweb/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/frontend/pweb/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/frontend/pweb/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/frontend/pweb/ios/Runner/AppDelegate.swift b/frontend/pweb/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/frontend/pweb/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..1f38fb1 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..26225d9 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..ef13c47 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..b805c99 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..eb55cb1 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..1939a8b Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..ce139cc Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..ef13c47 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..c2b8092 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..d5f6628 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..48c94a5 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..6e17cde Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..5891901 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..7fce686 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..d5f6628 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..ba0c5ba Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..23fa0d8 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..7059c6d Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..0905fb6 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..0d3a903 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..b7e9701 Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/frontend/pweb/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/frontend/pweb/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/pweb/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/frontend/pweb/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/ios/Runner/Base.lproj/Main.storyboard b/frontend/pweb/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/frontend/pweb/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/ios/Runner/Info.plist b/frontend/pweb/ios/Runner/Info.plist new file mode 100644 index 0000000..8f80ced --- /dev/null +++ b/frontend/pweb/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Web + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + web + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/frontend/pweb/ios/Runner/Runner-Bridging-Header.h b/frontend/pweb/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/frontend/pweb/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/frontend/pweb/ios/RunnerTests/RunnerTests.swift b/frontend/pweb/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/frontend/pweb/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/pweb/l10n.yaml b/frontend/pweb/l10n.yaml new file mode 100644 index 0000000..da8a858 --- /dev/null +++ b/frontend/pweb/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +output-dir: lib/generated/i18n +template-arb-file: en.arb +output-localization-file: app_localizations.dart +untranslated-messages-file: untranslated.txt \ No newline at end of file diff --git a/frontend/pweb/lib/app/app.dart b/frontend/pweb/lib/app/app.dart new file mode 100644 index 0000000..f6edcc6 --- /dev/null +++ b/frontend/pweb/lib/app/app.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/config/constants.dart'; +import 'package:pshared/provider/locale.dart'; + +import 'package:pweb/app/router/router.dart'; + +import 'package:pshared/generated/i18n/ps_localizations.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +final _router = createRouter(); + +class PayApp extends StatelessWidget { + const PayApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp.router( + title: 'Profee Pay', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Constants.themeColor), + useMaterial3: true, + ), + routerConfig: _router, + localizationsDelegates: [ + ...PSLocalizations.localizationsDelegates, + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: context.watch().locale, + ); +} diff --git a/frontend/pweb/lib/app/locale_manager.dart b/frontend/pweb/lib/app/locale_manager.dart new file mode 100644 index 0000000..31804b3 --- /dev/null +++ b/frontend/pweb/lib/app/locale_manager.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/find_locale.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:pshared/provider/locale.dart'; + + +String _localeVarStorageName() { + return 'mcrm_last_locale'; +} + +Locale _selectDefaultLocale(List appLocales, Locale defaultLocale) { + return appLocales.contains(defaultLocale) + ? defaultLocale + : appLocales.isEmpty + ? throw ArgumentError('empty application locales list', 'appLocales') + : appLocales.first; +} + + +class LocaleManager { + late SharedPreferences _prefs; + final List appLocales; + final Locale _defaultLocale; + final LocaleProvider localeProvider; + + LocaleManager(this.localeProvider, this.appLocales, Locale defaultLocale) + : _defaultLocale = _selectDefaultLocale(appLocales, defaultLocale) { + SharedPreferences.getInstance().then((prefs) { + _prefs = prefs; + _initializeLocaleProvider(); + }); + } + + Future _initializeLocaleProvider() async { + final initialLocale = await _getInitialLocale(); + localeProvider.setLocale(initialLocale); + localeProvider.addListener(_onLocaleChanged); + } + + Future _getInitialLocale() async { + final locale = await _pickLocale(); + return appLocales.contains(locale) ? locale : _defaultLocale; + } + + Future _pickLocale() async { + String? savedLocaleCode = _prefs.getString(_localeVarStorageName()); + if (savedLocaleCode != null) { + return Locale(savedLocaleCode); + } + + String systemLocaleString = await findSystemLocale(); + final List localeParts = systemLocaleString.split('_'); + final Locale systemLocale = Locale(localeParts[0]); + + final res = appLocales.contains(systemLocale); + + return res ? systemLocale : _defaultLocale; + } + + Future saveLocale(Locale locale) async { + return _prefs.setString(_localeVarStorageName(), locale.toString()); + } + + Future _onLocaleChanged() async { + return saveLocale(localeProvider.locale); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/app/router/page_params.dart b/frontend/pweb/lib/app/router/page_params.dart new file mode 100644 index 0000000..e0aab00 --- /dev/null +++ b/frontend/pweb/lib/app/router/page_params.dart @@ -0,0 +1,15 @@ +enum PageParams { + token, + projectRef, + roleRef, + taskRef, + invitationRef, +} + +String routerPageParam(PageParams param) { + return ':${param.name}'; +} + +String routerAddParam(PageParams param) { + return '/${routerPageParam(param)}'; +} \ No newline at end of file diff --git a/frontend/pweb/lib/app/router/pages.dart b/frontend/pweb/lib/app/router/pages.dart new file mode 100644 index 0000000..a7e0005 --- /dev/null +++ b/frontend/pweb/lib/app/router/pages.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +enum Pages { + root, + sfactor, + login, + methods, + verify, + signup, + settings, + dashboard, + profile, + recipients, + users, + roles, + permissions, + invitations, +} + +String routerPath(String page) { + return '/$page'; +} + +String routerPage(Pages page) { + return page == Pages.root ? '/' : routerPath(page.name); +} + +String _pagePath(Pages page, {String? objectRef}) => _pagesPath([page], objectRef: objectRef); + +String _pagesPath(List pages, {String? objectRef}) { + final path = pages.map(routerPage).join(); + return objectRef != null ? '$path/$objectRef' : path; +} + +void navigateAndReplace(BuildContext context, Pages page, {String? objectRef, Object? extra}) { + context.go(_pagePath(page, objectRef: objectRef), extra: extra); +} + +void navigate(BuildContext context, Pages page, {String? objectRef, Object? extra}) { + navigatePages(context, [page], objectRef: objectRef, extra: extra); +} + +void navigatePages(BuildContext context, List pages, {String? objectRef, Object? extra}) { + context.push(_pagesPath(pages, objectRef: objectRef), extra: extra); +} + +void navigateNamed(BuildContext context, Pages page, {String? objectRef, Object? extra}) { + context.pushNamed(page.name, extra: extra); +} + + +void navigateNamedAndReplace(BuildContext context, Pages page, {String? objectRef, Object? extra}) { + context.replaceNamed(page.name, extra: extra); +} + +void navigateNext(BuildContext context, Pages page, {Object? extra}) { + WidgetsBinding.instance.addPostFrameCallback((_) => navigate(context, page, extra: extra)); +} \ No newline at end of file diff --git a/frontend/pweb/lib/app/router/router.dart b/frontend/pweb/lib/app/router/router.dart new file mode 100644 index 0000000..e83df5d --- /dev/null +++ b/frontend/pweb/lib/app/router/router.dart @@ -0,0 +1,57 @@ +import 'package:go_router/go_router.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/pages/2fa/page.dart'; +import 'package:pweb/pages/signup/page.dart'; +import 'package:pweb/widgets/sidebar/page.dart'; +import 'package:pweb/pages/login/page.dart'; +import 'package:pweb/pages/errors/not_found.dart'; + +GoRouter createRouter() => GoRouter( + debugLogDiagnostics: true, + routes: [ + GoRoute( + name: Pages.root.name, + path: routerPage(Pages.root), + builder: (_, __) => const LoginPage(), + routes: [ + GoRoute( + name: Pages.login.name, + path: routerPage(Pages.login), + builder: (_, __) => const LoginPage(), + ), + GoRoute( + name: Pages.dashboard.name, + path: routerPage(Pages.dashboard), + builder: (_, __) => const PageSelector(), + ), + GoRoute( + name: Pages.sfactor.name, + path: routerPage(Pages.sfactor), + builder: (context, state) { + // Определяем откуда пришел пользователь + final isFromSignup = state.uri.queryParameters['from'] == 'signup'; + + return TwoFactorCodePage( + onVerificationSuccess: () { + if (isFromSignup) { + // После регистрации -> на страницу логина + context.goNamed(Pages.login.name); + } else { + // После логина -> на дашборд + context.goNamed(Pages.dashboard.name); + } + }, + ); + }, + ), + GoRoute( + name: Pages.signup.name, + path: routerPage(Pages.signup), + builder: (_, __) => const SignUpPage(), + ), + ], + ), + ], + errorBuilder: (_, __) => const NotFoundPage(), +); diff --git a/frontend/pweb/lib/app/timeago.dart b/frontend/pweb/lib/app/timeago.dart new file mode 100644 index 0000000..f4dfa50 --- /dev/null +++ b/frontend/pweb/lib/app/timeago.dart @@ -0,0 +1,27 @@ +import 'package:timeago/timeago.dart' as timeago; + +import 'package:pweb/generated/i18n/app_localizations.dart'; // Ensure this file exports supportedLocales + +// Mapping of language codes to timeago message classes. +final Map _timeagoLocales = { + 'en': timeago.EnMessages(), + 'ru': timeago.RuMessages(), + 'uk': timeago.UkMessages(), + // Add more mappings as needed. +}; + +/// Initializes timeago using the supported locales from AppLocalisations. +/// Optionally, [defaultLocale] can be set (defaults to 'en'). +void initializeTimeagoLocales({String defaultLocale = 'en'}) { + // Assume AppLocalisations.supportedLocales is a static List + final supportedLocales = AppLocalizations.supportedLocales; + + for (final locale in supportedLocales) { + final languageCode = locale.languageCode; + if (_timeagoLocales.containsKey(languageCode)) { + timeago.setLocaleMessages(languageCode, _timeagoLocales[languageCode]!); + } + } + // Set the default locale. + timeago.setDefaultLocale(defaultLocale); +} diff --git a/frontend/pweb/lib/config/constants.dart b/frontend/pweb/lib/config/constants.dart new file mode 100644 index 0000000..d6b52bc --- /dev/null +++ b/frontend/pweb/lib/config/constants.dart @@ -0,0 +1,10 @@ +class Constants { + static const minPasswordCharacters = 8; +} + +class AppConfig { + static const String appName = String.fromEnvironment( + 'APP_NAME', + defaultValue: 'SendiCo', + ); +} diff --git a/frontend/pweb/lib/generated/i18n/app_localizations.dart b/frontend/pweb/lib/generated/i18n/app_localizations.dart new file mode 100644 index 0000000..91889a2 --- /dev/null +++ b/frontend/pweb/lib/generated/i18n/app_localizations.dart @@ -0,0 +1,1562 @@ +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_en.dart'; +import 'app_localizations_ru.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 'i18n/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('en'), + Locale('ru'), + ]; + + /// No description provided for @login. + /// + /// In en, this message translates to: + /// **'Login'** + String get login; + + /// No description provided for @logout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// No description provided for @profile. + /// + /// In en, this message translates to: + /// **'Profile'** + String get profile; + + /// No description provided for @signup. + /// + /// In en, this message translates to: + /// **'Sign up'** + String get signup; + + /// No description provided for @username. + /// + /// In en, this message translates to: + /// **'Email'** + String get username; + + /// No description provided for @usernameHint. + /// + /// In en, this message translates to: + /// **'email@example.com'** + String get usernameHint; + + /// No description provided for @usernameErrorInvalid. + /// + /// In en, this message translates to: + /// **'Provide a valid email address'** + String get usernameErrorInvalid; + + /// No description provided for @usernameUnknownTLD. + /// + /// In en, this message translates to: + /// **'Domain .{domain} is not known, please, check it'** + String usernameUnknownTLD(Object domain); + + /// No description provided for @password. + /// + /// In en, this message translates to: + /// **'Password'** + String get password; + + /// No description provided for @confirmPassword. + /// + /// In en, this message translates to: + /// **'Confirm password'** + String get confirmPassword; + + /// No description provided for @passwordValidationRuleDigit. + /// + /// In en, this message translates to: + /// **'has digit'** + String get passwordValidationRuleDigit; + + /// No description provided for @passwordValidationRuleUpperCase. + /// + /// In en, this message translates to: + /// **'has uppercase letter'** + String get passwordValidationRuleUpperCase; + + /// No description provided for @passwordValidationRuleLowerCase. + /// + /// In en, this message translates to: + /// **'has lowercase letter'** + String get passwordValidationRuleLowerCase; + + /// No description provided for @passwordValidationRuleSpecialCharacter. + /// + /// In en, this message translates to: + /// **'has special character letter'** + String get passwordValidationRuleSpecialCharacter; + + /// No description provided for @passwordValidationRuleMinCharacters. + /// + /// In en, this message translates to: + /// **'is {charNum} characters long at least'** + String passwordValidationRuleMinCharacters(Object charNum); + + /// No description provided for @passwordsDoNotMatch. + /// + /// In en, this message translates to: + /// **'Passwords do not match'** + String get passwordsDoNotMatch; + + /// No description provided for @passwordValidationError. + /// + /// In en, this message translates to: + /// **'Check that your password {matchesCriteria}'** + String passwordValidationError(Object matchesCriteria); + + /// No description provided for @notificationError. + /// + /// In en, this message translates to: + /// **'Error occurred: {error}'** + String notificationError(Object error); + + /// No description provided for @loginUserNotFound. + /// + /// In en, this message translates to: + /// **'Account {account} has not been registered in the system'** + String loginUserNotFound(Object account); + + /// No description provided for @loginPasswordIncorrect. + /// + /// In en, this message translates to: + /// **'Authorization failed, please check your password'** + String get loginPasswordIncorrect; + + /// No description provided for @internalErrorOccurred. + /// + /// In en, this message translates to: + /// **'An internal server error occurred: {error}, we already know about it and working hard to fix it'** + String internalErrorOccurred(Object error); + + /// No description provided for @noErrorInformation. + /// + /// In en, this message translates to: + /// **'Some error occurred, but we have not error information. We are already investigating the issue'** + String get noErrorInformation; + + /// No description provided for @yourName. + /// + /// In en, this message translates to: + /// **'Your name'** + String get yourName; + + /// No description provided for @nameHint. + /// + /// In en, this message translates to: + /// **'John Doe'** + String get nameHint; + + /// No description provided for @errorPageNotFoundTitle. + /// + /// In en, this message translates to: + /// **'Page Not Found'** + String get errorPageNotFoundTitle; + + /// No description provided for @errorPageNotFoundMessage. + /// + /// In en, this message translates to: + /// **'Oops! We couldn\'t find that page.'** + String get errorPageNotFoundMessage; + + /// No description provided for @errorPageNotFoundHint. + /// + /// In en, this message translates to: + /// **'The page you\'re looking for doesn\'t exist or has been moved. Please check the URL or return to the home page.'** + String get errorPageNotFoundHint; + + /// No description provided for @errorUnknown. + /// + /// In en, this message translates to: + /// **'Unknown error occurred'** + String get errorUnknown; + + /// No description provided for @unknown. + /// + /// In en, this message translates to: + /// **'unknown'** + String get unknown; + + /// No description provided for @goToLogin. + /// + /// In en, this message translates to: + /// **'Go to Login'** + String get goToLogin; + + /// No description provided for @goBack. + /// + /// In en, this message translates to: + /// **'Go Back'** + String get goBack; + + /// No description provided for @goToMainPage. + /// + /// In en, this message translates to: + /// **'Go to Main Page'** + String get goToMainPage; + + /// No description provided for @goToSignUp. + /// + /// In en, this message translates to: + /// **'Go to Sign Up'** + String get goToSignUp; + + /// No description provided for @signupError. + /// + /// In en, this message translates to: + /// **'Failed to signup: {error}'** + String signupError(Object error); + + /// No description provided for @signupSuccess. + /// + /// In en, this message translates to: + /// **'Email confirmation message has been sent to {email}. Please, open it and click link to activate your account.'** + String signupSuccess(Object email); + + /// No description provided for @connectivityError. + /// + /// In en, this message translates to: + /// **'Cannot reach the server at {serverAddress}. Check your network and try again.'** + String connectivityError(Object serverAddress); + + /// No description provided for @errorAccountExists. + /// + /// In en, this message translates to: + /// **'Account already exists'** + String get errorAccountExists; + + /// No description provided for @errorAccountNotVerified. + /// + /// In en, this message translates to: + /// **'Your account hasn\'t been verified yet. Please check your email to complete the verification'** + String get errorAccountNotVerified; + + /// No description provided for @errorLoginUnauthorized. + /// + /// In en, this message translates to: + /// **'Login or password is incorrect. Please try again'** + String get errorLoginUnauthorized; + + /// No description provided for @errorInternalError. + /// + /// In en, this message translates to: + /// **'An internal error occurred. We\'re aware of the issue and working to resolve it. Please try again later'** + String get errorInternalError; + + /// No description provided for @errorVerificationTokenNotFound. + /// + /// In en, this message translates to: + /// **'Account for verification not found. Sign up again'** + String get errorVerificationTokenNotFound; + + /// No description provided for @created. + /// + /// In en, this message translates to: + /// **'Created'** + String get created; + + /// No description provided for @edited. + /// + /// In en, this message translates to: + /// **'Edited'** + String get edited; + + /// No description provided for @errorDataConflict. + /// + /// In en, this message translates to: + /// **'We can’t process your data because it has conflicting or contradictory information.'** + String get errorDataConflict; + + /// No description provided for @errorAccessDenied. + /// + /// In en, this message translates to: + /// **'You do not have permission to access this resource. If you need access, please contact an administrator.'** + String get errorAccessDenied; + + /// No description provided for @errorBrokenPayload. + /// + /// In en, this message translates to: + /// **'The data you sent is invalid or incomplete. Please check your submission and try again.'** + String get errorBrokenPayload; + + /// No description provided for @errorInvalidArgument. + /// + /// In en, this message translates to: + /// **'One or more arguments are invalid. Verify your input and try again.'** + String get errorInvalidArgument; + + /// No description provided for @errorBrokenReference. + /// + /// In en, this message translates to: + /// **'The resource you\'re trying to access could not be referenced. It may have been moved or deleted.'** + String get errorBrokenReference; + + /// No description provided for @errorInvalidQueryParameter. + /// + /// In en, this message translates to: + /// **'One or more query parameters are missing or incorrect. Check them and try again.'** + String get errorInvalidQueryParameter; + + /// No description provided for @errorNotImplemented. + /// + /// In en, this message translates to: + /// **'This feature is not yet available. Please try again later or contact support.'** + String get errorNotImplemented; + + /// No description provided for @errorLicenseRequired. + /// + /// In en, this message translates to: + /// **'A valid license is required to perform this action. Please contact your administrator.'** + String get errorLicenseRequired; + + /// No description provided for @errorNotFound. + /// + /// In en, this message translates to: + /// **'We couldn\'t find the resource you requested. It may have been removed or is temporarily unavailable.'** + String get errorNotFound; + + /// No description provided for @errorNameMissing. + /// + /// In en, this message translates to: + /// **'Please provide a name before continuing.'** + String get errorNameMissing; + + /// No description provided for @errorEmailMissing. + /// + /// In en, this message translates to: + /// **'Please provide an email address before continuing.'** + String get errorEmailMissing; + + /// No description provided for @errorPasswordMissing. + /// + /// In en, this message translates to: + /// **'Please provide a password before continuing.'** + String get errorPasswordMissing; + + /// No description provided for @errorEmailNotRegistered. + /// + /// In en, this message translates to: + /// **'We could not find an account associated with that email address.'** + String get errorEmailNotRegistered; + + /// No description provided for @errorDuplicateEmail. + /// + /// In en, this message translates to: + /// **'This email address is already in use. Try another one or reset your password.'** + String get errorDuplicateEmail; + + /// No description provided for @showDetailsAction. + /// + /// In en, this message translates to: + /// **'Show Details'** + String get showDetailsAction; + + /// No description provided for @errorLogin. + /// + /// In en, this message translates to: + /// **'Error logging in'** + String get errorLogin; + + /// Error message displayed when invitation creation fails + /// + /// In en, this message translates to: + /// **'Failed to create invitaiton'** + String get errorCreatingInvitation; + + /// No description provided for @footerCompanyName. + /// + /// In en, this message translates to: + /// **'Sibilla Solutions LTD'** + String get footerCompanyName; + + /// No description provided for @footerAddress. + /// + /// In en, this message translates to: + /// **'27, Pindarou Street, Alpha Business Centre, Block B 7th Floor, 1060 Nicosia, Cyprus'** + String get footerAddress; + + /// No description provided for @footerSupport. + /// + /// In en, this message translates to: + /// **'Support'** + String get footerSupport; + + /// No description provided for @footerEmail. + /// + /// In en, this message translates to: + /// **'Email TBD'** + String get footerEmail; + + /// No description provided for @footerPhoneLabel. + /// + /// In en, this message translates to: + /// **'Phone'** + String get footerPhoneLabel; + + /// No description provided for @footerPhone. + /// + /// In en, this message translates to: + /// **'+357 22 000 253'** + String get footerPhone; + + /// No description provided for @footerTermsOfService. + /// + /// In en, this message translates to: + /// **'Terms of Service'** + String get footerTermsOfService; + + /// No description provided for @footerPrivacyPolicy. + /// + /// In en, this message translates to: + /// **'Privacy Policy'** + String get footerPrivacyPolicy; + + /// No description provided for @footerCookiePolicy. + /// + /// In en, this message translates to: + /// **'Cookie Policy'** + String get footerCookiePolicy; + + /// No description provided for @navigationLogout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get navigationLogout; + + /// No description provided for @dashboard. + /// + /// In en, this message translates to: + /// **'Dashboard'** + String get dashboard; + + /// No description provided for @navigationUsersSettings. + /// + /// In en, this message translates to: + /// **'Users'** + String get navigationUsersSettings; + + /// No description provided for @navigationRolesSettings. + /// + /// In en, this message translates to: + /// **'Roles'** + String get navigationRolesSettings; + + /// No description provided for @navigationPermissionsSettings. + /// + /// In en, this message translates to: + /// **'Permissions'** + String get navigationPermissionsSettings; + + /// No description provided for @usersManagement. + /// + /// In en, this message translates to: + /// **'User Management'** + String get usersManagement; + + /// No description provided for @navigationOrganizationSettings. + /// + /// In en, this message translates to: + /// **'Organization settings'** + String get navigationOrganizationSettings; + + /// No description provided for @navigationAccountSettings. + /// + /// In en, this message translates to: + /// **'Profile settings'** + String get navigationAccountSettings; + + /// No description provided for @twoFactorPrompt. + /// + /// In en, this message translates to: + /// **'Enter the 6-digit code we sent to your device'** + String get twoFactorPrompt; + + /// No description provided for @twoFactorResend. + /// + /// In en, this message translates to: + /// **'Didn’t receive a code? Resend'** + String get twoFactorResend; + + /// No description provided for @twoFactorTitle. + /// + /// In en, this message translates to: + /// **'Two-Factor Authentication'** + String get twoFactorTitle; + + /// No description provided for @twoFactorError. + /// + /// In en, this message translates to: + /// **'Invalid code. Please try again.'** + String get twoFactorError; + + /// No description provided for @payoutNavDashboard. + /// + /// In en, this message translates to: + /// **'Dashboard'** + String get payoutNavDashboard; + + /// No description provided for @payoutNavSendPayout. + /// + /// In en, this message translates to: + /// **'Send payout'** + String get payoutNavSendPayout; + + /// No description provided for @payoutNavRecipients. + /// + /// In en, this message translates to: + /// **'Recipients'** + String get payoutNavRecipients; + + /// No description provided for @payoutNavReports. + /// + /// In en, this message translates to: + /// **'Reports'** + String get payoutNavReports; + + /// No description provided for @payoutNavSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get payoutNavSettings; + + /// No description provided for @payoutNavLogout. + /// + /// In en, this message translates to: + /// **'Logout'** + String get payoutNavLogout; + + /// No description provided for @payoutNavMethods. + /// + /// In en, this message translates to: + /// **'Payouts'** + String get payoutNavMethods; + + /// No description provided for @expand. + /// + /// In en, this message translates to: + /// **'Expand'** + String get expand; + + /// No description provided for @collapse. + /// + /// In en, this message translates to: + /// **'Collapse'** + String get collapse; + + /// Title of the recipient address book page + /// + /// In en, this message translates to: + /// **'Recipient address book'** + String get pageTitleRecipients; + + /// Tooltip and button label to add a new recipient + /// + /// In en, this message translates to: + /// **'Add new'** + String get actionAddNew; + + /// Column header for who manages the payout data + /// + /// In en, this message translates to: + /// **'Data owner'** + String get colDataOwner; + + /// Column header for recipient avatar + /// + /// In en, this message translates to: + /// **'Avatar'** + String get colAvatar; + + /// Column header for recipient name + /// + /// In en, this message translates to: + /// **'Name'** + String get colName; + + /// Column header for recipient email address + /// + /// In en, this message translates to: + /// **'Email'** + String get colEmail; + + /// Column header for payout readiness status + /// + /// In en, this message translates to: + /// **'Status'** + String get colStatus; + + /// Status indicating payouts can be sent immediately + /// + /// In en, this message translates to: + /// **'Ready'** + String get statusReady; + + /// Status indicating recipient is registered but not yet fully ready + /// + /// In en, this message translates to: + /// **'Registered'** + String get statusRegistered; + + /// Status indicating recipient has not completed registration + /// + /// In en, this message translates to: + /// **'Not registered'** + String get statusNotRegistered; + + /// Label for recipients whose payout data is managed internally by the user/company + /// + /// In en, this message translates to: + /// **'Managed by me'** + String get typeInternal; + + /// Label for recipients who manage their own payout data + /// + /// In en, this message translates to: + /// **'Self‑managed'** + String get typeExternal; + + /// No description provided for @searchHint. + /// + /// In en, this message translates to: + /// **'Search recipients'** + String get searchHint; + + /// No description provided for @colActions. + /// + /// In en, this message translates to: + /// **'Actions'** + String get colActions; + + /// No description provided for @menuEdit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get menuEdit; + + /// No description provided for @menuSendPayout. + /// + /// In en, this message translates to: + /// **'Send payout'** + String get menuSendPayout; + + /// No description provided for @tooltipRowActions. + /// + /// In en, this message translates to: + /// **'More actions'** + String get tooltipRowActions; + + /// No description provided for @accountSettings. + /// + /// In en, this message translates to: + /// **'Account Settings'** + String get accountSettings; + + /// No description provided for @accountNameUpdateError. + /// + /// In en, this message translates to: + /// **'Failed to update account name'** + String get accountNameUpdateError; + + /// No description provided for @settingsSuccessfullyUpdated. + /// + /// In en, this message translates to: + /// **'Settings successfully updated'** + String get settingsSuccessfullyUpdated; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @failedToUpdateLanguage. + /// + /// In en, this message translates to: + /// **'Failed to update language'** + String get failedToUpdateLanguage; + + /// No description provided for @settingsImageUpdateError. + /// + /// In en, this message translates to: + /// **'Couldn\'t update the image'** + String get settingsImageUpdateError; + + /// No description provided for @settingsImageTitle. + /// + /// In en, this message translates to: + /// **'Image'** + String get settingsImageTitle; + + /// No description provided for @settingsImageHint. + /// + /// In en, this message translates to: + /// **'Tap to change the image'** + String get settingsImageHint; + + /// No description provided for @accountName. + /// + /// In en, this message translates to: + /// **'Name'** + String get accountName; + + /// No description provided for @accountNameHint. + /// + /// In en, this message translates to: + /// **'Specify your name'** + String get accountNameHint; + + /// No description provided for @avatar. + /// + /// In en, this message translates to: + /// **'Profile photo'** + String get avatar; + + /// No description provided for @avatarHint. + /// + /// In en, this message translates to: + /// **'Tap to update'** + String get avatarHint; + + /// No description provided for @avatarUpdateError. + /// + /// In en, this message translates to: + /// **'Failed to update profile photo'** + String get avatarUpdateError; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @notSet. + /// + /// In en, this message translates to: + /// **'not set'** + String get notSet; + + /// No description provided for @search. + /// + /// In en, this message translates to: + /// **'Search...'** + String get search; + + /// No description provided for @ok. + /// + /// In en, this message translates to: + /// **'Ok'** + String get ok; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @back. + /// + /// In en, this message translates to: + /// **'Back'** + String get back; + + /// Title of the operation history page + /// + /// In en, this message translates to: + /// **'Operation history'** + String get operationfryTitle; + + /// Label for the filters expansion panel + /// + /// In en, this message translates to: + /// **'Filters'** + String get filters; + + /// Label for the date‐range filter + /// + /// In en, this message translates to: + /// **'Period'** + String get period; + + /// Placeholder when no period is selected + /// + /// In en, this message translates to: + /// **'Select period'** + String get selectPeriod; + + /// Button text to apply the filters + /// + /// In en, this message translates to: + /// **'Apply'** + String get apply; + + /// Template for a single status filter chip + /// + /// In en, this message translates to: + /// **'{status}'** + String status(String status); + + /// Status indicating the operation succeeded + /// + /// In en, this message translates to: + /// **'Successful'** + String get operationStatusSuccessful; + + /// Status indicating the operation is pending + /// + /// In en, this message translates to: + /// **'Pending'** + String get operationStatusPending; + + /// Status indicating the operation failed + /// + /// In en, this message translates to: + /// **'Unsuccessful'** + String get operationStatusUnsuccessful; + + /// Table column header for status + /// + /// In en, this message translates to: + /// **'Status'** + String get statusColumn; + + /// Table column header for file name + /// + /// In en, this message translates to: + /// **'File name'** + String get fileNameColumn; + + /// Table column header for the original amount + /// + /// In en, this message translates to: + /// **'Amount'** + String get amountColumn; + + /// Table column header for the converted amount + /// + /// In en, this message translates to: + /// **'To amount'** + String get toAmountColumn; + + /// Table column header for the payment ID + /// + /// In en, this message translates to: + /// **'Pay ID'** + String get payIdColumn; + + /// Table column header for the masked card number + /// + /// In en, this message translates to: + /// **'Card number'** + String get cardNumberColumn; + + /// Table column header for recipient name + /// + /// In en, this message translates to: + /// **'Name'** + String get nameColumn; + + /// Table column header for the date/time + /// + /// In en, this message translates to: + /// **'Date'** + String get dateColumn; + + /// Table column header for any comment + /// + /// In en, this message translates to: + /// **'Comment'** + String get commentColumn; + + /// No description provided for @paymentConfigTitle. + /// + /// In en, this message translates to: + /// **'Where to receive money'** + String get paymentConfigTitle; + + /// No description provided for @paymentConfigSubtitle. + /// + /// In en, this message translates to: + /// **'Add multiple methods and choose your primary one.'** + String get paymentConfigSubtitle; + + /// No description provided for @addPaymentMethod. + /// + /// In en, this message translates to: + /// **'Add payment method'** + String get addPaymentMethod; + + /// No description provided for @makeMain. + /// + /// In en, this message translates to: + /// **'Make primary'** + String get makeMain; + + /// No description provided for @advanced. + /// + /// In en, this message translates to: + /// **'Advanced'** + String get advanced; + + /// No description provided for @fallbackExplanation. + /// + /// In en, this message translates to: + /// **'If the primary method is unavailable, we will try the next enabled one in the list.'** + String get fallbackExplanation; + + /// Button label to delete a payment method + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// Confirmation dialog message shown before a payment method is removed + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this payment method?'** + String get deletePaymentConfirmation; + + /// Button label to edit a payment method + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// Tooltip for an overflow menu button that reveals extra actions for a payment method + /// + /// In en, this message translates to: + /// **'More actions'** + String get moreActions; + + /// No description provided for @noPayouts. + /// + /// In en, this message translates to: + /// **'No Payouts'** + String get noPayouts; + + /// No description provided for @enterBankName. + /// + /// In en, this message translates to: + /// **'Enter bank name'** + String get enterBankName; + + /// No description provided for @paymentType. + /// + /// In en, this message translates to: + /// **'Payment Method Type'** + String get paymentType; + + /// No description provided for @selectPaymentType. + /// + /// In en, this message translates to: + /// **'Please select a payment method type'** + String get selectPaymentType; + + /// No description provided for @paymentTypeCard. + /// + /// In en, this message translates to: + /// **'Credit Card'** + String get paymentTypeCard; + + /// No description provided for @paymentTypeBankAccount. + /// + /// In en, this message translates to: + /// **'Russian Bank Account'** + String get paymentTypeBankAccount; + + /// No description provided for @paymentTypeIban. + /// + /// In en, this message translates to: + /// **'IBAN'** + String get paymentTypeIban; + + /// No description provided for @paymentTypeWallet. + /// + /// In en, this message translates to: + /// **'Wallet'** + String get paymentTypeWallet; + + /// No description provided for @cardNumber. + /// + /// In en, this message translates to: + /// **'Card Number'** + String get cardNumber; + + /// No description provided for @enterCardNumber. + /// + /// In en, this message translates to: + /// **'Enter the card number'** + String get enterCardNumber; + + /// No description provided for @cardholderName. + /// + /// In en, this message translates to: + /// **'Cardholder Name'** + String get cardholderName; + + /// No description provided for @iban. + /// + /// In en, this message translates to: + /// **'IBAN'** + String get iban; + + /// No description provided for @enterIban. + /// + /// In en, this message translates to: + /// **'Enter IBAN'** + String get enterIban; + + /// No description provided for @bic. + /// + /// In en, this message translates to: + /// **'BIC'** + String get bic; + + /// No description provided for @bankName. + /// + /// In en, this message translates to: + /// **'Bank Name'** + String get bankName; + + /// No description provided for @accountHolder. + /// + /// In en, this message translates to: + /// **'Account Holder'** + String get accountHolder; + + /// No description provided for @enterAccountHolder. + /// + /// In en, this message translates to: + /// **'Enter account holder'** + String get enterAccountHolder; + + /// No description provided for @enterBic. + /// + /// In en, this message translates to: + /// **'Enter BIC'** + String get enterBic; + + /// No description provided for @walletId. + /// + /// In en, this message translates to: + /// **'Wallet ID'** + String get walletId; + + /// No description provided for @enterWalletId. + /// + /// In en, this message translates to: + /// **'Enter wallet ID'** + String get enterWalletId; + + /// No description provided for @recipients. + /// + /// In en, this message translates to: + /// **'Recipients'** + String get recipients; + + /// No description provided for @recipientName. + /// + /// In en, this message translates to: + /// **'Recipient Name'** + String get recipientName; + + /// No description provided for @enterRecipientName. + /// + /// In en, this message translates to: + /// **'Enter recipient name'** + String get enterRecipientName; + + /// No description provided for @inn. + /// + /// In en, this message translates to: + /// **'INN'** + String get inn; + + /// No description provided for @enterInn. + /// + /// In en, this message translates to: + /// **'Enter INN'** + String get enterInn; + + /// No description provided for @kpp. + /// + /// In en, this message translates to: + /// **'KPP'** + String get kpp; + + /// No description provided for @enterKpp. + /// + /// In en, this message translates to: + /// **'Enter KPP'** + String get enterKpp; + + /// No description provided for @accountNumber. + /// + /// In en, this message translates to: + /// **'Account Number'** + String get accountNumber; + + /// No description provided for @enterAccountNumber. + /// + /// In en, this message translates to: + /// **'Enter account number'** + String get enterAccountNumber; + + /// No description provided for @correspondentAccount. + /// + /// In en, this message translates to: + /// **'Correspondent Account'** + String get correspondentAccount; + + /// No description provided for @enterCorrespondentAccount. + /// + /// In en, this message translates to: + /// **'Enter correspondent account'** + String get enterCorrespondentAccount; + + /// No description provided for @bik. + /// + /// In en, this message translates to: + /// **'BIK'** + String get bik; + + /// No description provided for @enterBik. + /// + /// In en, this message translates to: + /// **'Enter BIK'** + String get enterBik; + + /// No description provided for @add. + /// + /// In en, this message translates to: + /// **'Add'** + String get add; + + /// No description provided for @expiryDate. + /// + /// In en, this message translates to: + /// **'Expiry (MM/YY)'** + String get expiryDate; + + /// No description provided for @firstName. + /// + /// In en, this message translates to: + /// **'First Name'** + String get firstName; + + /// No description provided for @enterFirstName. + /// + /// In en, this message translates to: + /// **'Enter First Name'** + String get enterFirstName; + + /// No description provided for @lastName. + /// + /// In en, this message translates to: + /// **'Last Name'** + String get lastName; + + /// No description provided for @enterLastName. + /// + /// In en, this message translates to: + /// **'Enter Last Name'** + String get enterLastName; + + /// No description provided for @sendSingle. + /// + /// In en, this message translates to: + /// **'Send single transaction'** + String get sendSingle; + + /// No description provided for @sendMultiple. + /// + /// In en, this message translates to: + /// **'Send multiple transactions'** + String get sendMultiple; + + /// No description provided for @addFunds. + /// + /// In en, this message translates to: + /// **'Add Funds'** + String get addFunds; + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// No description provided for @multiplePayout. + /// + /// In en, this message translates to: + /// **'Multiple Payout'** + String get multiplePayout; + + /// No description provided for @howItWorks. + /// + /// In en, this message translates to: + /// **'How it works?'** + String get howItWorks; + + /// No description provided for @exampleTitle. + /// + /// In en, this message translates to: + /// **'File Format & Sample'** + String get exampleTitle; + + /// No description provided for @downloadSampleCSV. + /// + /// In en, this message translates to: + /// **'Download sample.csv'** + String get downloadSampleCSV; + + /// No description provided for @tokenColumn. + /// + /// In en, this message translates to: + /// **'Token (required)'** + String get tokenColumn; + + /// No description provided for @currency. + /// + /// In en, this message translates to: + /// **'Currency'** + String get currency; + + /// No description provided for @amount. + /// + /// In en, this message translates to: + /// **'Amount'** + String get amount; + + /// No description provided for @comment. + /// + /// In en, this message translates to: + /// **'Comment'** + String get comment; + + /// No description provided for @uploadCSV. + /// + /// In en, this message translates to: + /// **'Upload your CSV'** + String get uploadCSV; + + /// No description provided for @upload. + /// + /// In en, this message translates to: + /// **'Upload'** + String get upload; + + /// No description provided for @hintUpload. + /// + /// In en, this message translates to: + /// **'Supported format: .CSV · Max size 1 MB'** + String get hintUpload; + + /// No description provided for @uploadHistory. + /// + /// In en, this message translates to: + /// **'Upload History'** + String get uploadHistory; + + /// No description provided for @payout. + /// + /// In en, this message translates to: + /// **'Payout'** + String get payout; + + /// No description provided for @sendTo. + /// + /// In en, this message translates to: + /// **'Send Payout To'** + String get sendTo; + + /// No description provided for @send. + /// + /// In en, this message translates to: + /// **'Send Payout'** + String get send; + + /// No description provided for @recipientPaysFee. + /// + /// In en, this message translates to: + /// **'Recipient pays the fee'** + String get recipientPaysFee; + + /// Label showing the amount sent + /// + /// In en, this message translates to: + /// **'Sent amount: \${amount}'** + String sentAmount(String amount); + + /// Label showing the transaction fee + /// + /// In en, this message translates to: + /// **'Fee: \${fee}'** + String fee(String fee); + + /// Label showing how much the recipient will receive + /// + /// In en, this message translates to: + /// **'Recipient will receive: \${amount}'** + String recipientWillReceive(String amount); + + /// Label showing the total amount of the transaction + /// + /// In en, this message translates to: + /// **'Total: \${total}'** + String total(String total); + + /// No description provided for @hideDetails. + /// + /// In en, this message translates to: + /// **'Hide Details'** + String get hideDetails; + + /// No description provided for @showDetails. + /// + /// In en, this message translates to: + /// **'Show Details'** + String get showDetails; + + /// No description provided for @whereGetMoney. + /// + /// In en, this message translates to: + /// **'Source of funds for debit'** + String get whereGetMoney; + + /// No description provided for @details. + /// + /// In en, this message translates to: + /// **'Details'** + String get details; + + /// No description provided for @addRecipient. + /// + /// In en, this message translates to: + /// **'Add Recipient'** + String get addRecipient; + + /// No description provided for @editRecipient. + /// + /// In en, this message translates to: + /// **'Edit Recipient'** + String get editRecipient; + + /// No description provided for @saveRecipient. + /// + /// In en, this message translates to: + /// **'Save Recipient'** + String get saveRecipient; + + /// No description provided for @choosePaymentMethod. + /// + /// In en, this message translates to: + /// **'Payment Methods (choose at least 1)'** + String get choosePaymentMethod; + + /// No description provided for @recipientFormRule. + /// + /// In en, this message translates to: + /// **'Recipient must have at least one payment method'** + String get recipientFormRule; + + /// No description provided for @allStatus. + /// + /// In en, this message translates to: + /// **'All'** + String get allStatus; + + /// No description provided for @readyStatus. + /// + /// In en, this message translates to: + /// **'Ready'** + String get readyStatus; + + /// No description provided for @registeredStatus. + /// + /// In en, this message translates to: + /// **'Registered'** + String get registeredStatus; + + /// No description provided for @notRegisteredStatus. + /// + /// In en, this message translates to: + /// **'Not registered'** + String get notRegisteredStatus; + + /// No description provided for @noRecipientSelected. + /// + /// In en, this message translates to: + /// **'No recipient selected'** + String get noRecipientSelected; + + /// No description provided for @companyName. + /// + /// In en, this message translates to: + /// **'Name of your company'** + String get companyName; + + /// No description provided for @companynameRequired. + /// + /// In en, this message translates to: + /// **'Company name required'** + String get companynameRequired; + + /// No description provided for @errorSignUp. + /// + /// In en, this message translates to: + /// **'Error occured while signing up, try again later'** + String get errorSignUp; + + /// No description provided for @companyDescription. + /// + /// In en, this message translates to: + /// **'Company Description'** + String get companyDescription; + + /// No description provided for @companyDescriptionHint. + /// + /// In en, this message translates to: + /// **'Describe any of the fields of the Company\'s business'** + String get companyDescriptionHint; + + /// No description provided for @optional. + /// + /// In en, this message translates to: + /// **'optional'** + String get optional; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].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 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + 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/frontend/pweb/lib/generated/i18n/app_localizations_en.dart b/frontend/pweb/lib/generated/i18n/app_localizations_en.dart new file mode 100644 index 0000000..ddcad3b --- /dev/null +++ b/frontend/pweb/lib/generated/i18n/app_localizations_en.dart @@ -0,0 +1,779 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get login => 'Login'; + + @override + String get logout => 'Logout'; + + @override + String get profile => 'Profile'; + + @override + String get signup => 'Sign up'; + + @override + String get username => 'Email'; + + @override + String get usernameHint => 'email@example.com'; + + @override + String get usernameErrorInvalid => 'Provide a valid email address'; + + @override + String usernameUnknownTLD(Object domain) { + return 'Domain .$domain is not known, please, check it'; + } + + @override + String get password => 'Password'; + + @override + String get confirmPassword => 'Confirm password'; + + @override + String get passwordValidationRuleDigit => 'has digit'; + + @override + String get passwordValidationRuleUpperCase => 'has uppercase letter'; + + @override + String get passwordValidationRuleLowerCase => 'has lowercase letter'; + + @override + String get passwordValidationRuleSpecialCharacter => + 'has special character letter'; + + @override + String passwordValidationRuleMinCharacters(Object charNum) { + return 'is $charNum characters long at least'; + } + + @override + String get passwordsDoNotMatch => 'Passwords do not match'; + + @override + String passwordValidationError(Object matchesCriteria) { + return 'Check that your password $matchesCriteria'; + } + + @override + String notificationError(Object error) { + return 'Error occurred: $error'; + } + + @override + String loginUserNotFound(Object account) { + return 'Account $account has not been registered in the system'; + } + + @override + String get loginPasswordIncorrect => + 'Authorization failed, please check your password'; + + @override + String internalErrorOccurred(Object error) { + return 'An internal server error occurred: $error, we already know about it and working hard to fix it'; + } + + @override + String get noErrorInformation => + 'Some error occurred, but we have not error information. We are already investigating the issue'; + + @override + String get yourName => 'Your name'; + + @override + String get nameHint => 'John Doe'; + + @override + String get errorPageNotFoundTitle => 'Page Not Found'; + + @override + String get errorPageNotFoundMessage => 'Oops! We couldn\'t find that page.'; + + @override + String get errorPageNotFoundHint => + 'The page you\'re looking for doesn\'t exist or has been moved. Please check the URL or return to the home page.'; + + @override + String get errorUnknown => 'Unknown error occurred'; + + @override + String get unknown => 'unknown'; + + @override + String get goToLogin => 'Go to Login'; + + @override + String get goBack => 'Go Back'; + + @override + String get goToMainPage => 'Go to Main Page'; + + @override + String get goToSignUp => 'Go to Sign Up'; + + @override + String signupError(Object error) { + return 'Failed to signup: $error'; + } + + @override + String signupSuccess(Object email) { + return 'Email confirmation message has been sent to $email. Please, open it and click link to activate your account.'; + } + + @override + String connectivityError(Object serverAddress) { + return 'Cannot reach the server at $serverAddress. Check your network and try again.'; + } + + @override + String get errorAccountExists => 'Account already exists'; + + @override + String get errorAccountNotVerified => + 'Your account hasn\'t been verified yet. Please check your email to complete the verification'; + + @override + String get errorLoginUnauthorized => + 'Login or password is incorrect. Please try again'; + + @override + String get errorInternalError => + 'An internal error occurred. We\'re aware of the issue and working to resolve it. Please try again later'; + + @override + String get errorVerificationTokenNotFound => + 'Account for verification not found. Sign up again'; + + @override + String get created => 'Created'; + + @override + String get edited => 'Edited'; + + @override + String get errorDataConflict => + 'We can’t process your data because it has conflicting or contradictory information.'; + + @override + String get errorAccessDenied => + 'You do not have permission to access this resource. If you need access, please contact an administrator.'; + + @override + String get errorBrokenPayload => + 'The data you sent is invalid or incomplete. Please check your submission and try again.'; + + @override + String get errorInvalidArgument => + 'One or more arguments are invalid. Verify your input and try again.'; + + @override + String get errorBrokenReference => + 'The resource you\'re trying to access could not be referenced. It may have been moved or deleted.'; + + @override + String get errorInvalidQueryParameter => + 'One or more query parameters are missing or incorrect. Check them and try again.'; + + @override + String get errorNotImplemented => + 'This feature is not yet available. Please try again later or contact support.'; + + @override + String get errorLicenseRequired => + 'A valid license is required to perform this action. Please contact your administrator.'; + + @override + String get errorNotFound => + 'We couldn\'t find the resource you requested. It may have been removed or is temporarily unavailable.'; + + @override + String get errorNameMissing => 'Please provide a name before continuing.'; + + @override + String get errorEmailMissing => + 'Please provide an email address before continuing.'; + + @override + String get errorPasswordMissing => + 'Please provide a password before continuing.'; + + @override + String get errorEmailNotRegistered => + 'We could not find an account associated with that email address.'; + + @override + String get errorDuplicateEmail => + 'This email address is already in use. Try another one or reset your password.'; + + @override + String get showDetailsAction => 'Show Details'; + + @override + String get errorLogin => 'Error logging in'; + + @override + String get errorCreatingInvitation => 'Failed to create invitaiton'; + + @override + String get footerCompanyName => 'Sibilla Solutions LTD'; + + @override + String get footerAddress => + '27, Pindarou Street, Alpha Business Centre, Block B 7th Floor, 1060 Nicosia, Cyprus'; + + @override + String get footerSupport => 'Support'; + + @override + String get footerEmail => 'Email TBD'; + + @override + String get footerPhoneLabel => 'Phone'; + + @override + String get footerPhone => '+357 22 000 253'; + + @override + String get footerTermsOfService => 'Terms of Service'; + + @override + String get footerPrivacyPolicy => 'Privacy Policy'; + + @override + String get footerCookiePolicy => 'Cookie Policy'; + + @override + String get navigationLogout => 'Logout'; + + @override + String get dashboard => 'Dashboard'; + + @override + String get navigationUsersSettings => 'Users'; + + @override + String get navigationRolesSettings => 'Roles'; + + @override + String get navigationPermissionsSettings => 'Permissions'; + + @override + String get usersManagement => 'User Management'; + + @override + String get navigationOrganizationSettings => 'Organization settings'; + + @override + String get navigationAccountSettings => 'Profile settings'; + + @override + String get twoFactorPrompt => 'Enter the 6-digit code we sent to your device'; + + @override + String get twoFactorResend => 'Didn’t receive a code? Resend'; + + @override + String get twoFactorTitle => 'Two-Factor Authentication'; + + @override + String get twoFactorError => 'Invalid code. Please try again.'; + + @override + String get payoutNavDashboard => 'Dashboard'; + + @override + String get payoutNavSendPayout => 'Send payout'; + + @override + String get payoutNavRecipients => 'Recipients'; + + @override + String get payoutNavReports => 'Reports'; + + @override + String get payoutNavSettings => 'Settings'; + + @override + String get payoutNavLogout => 'Logout'; + + @override + String get payoutNavMethods => 'Payouts'; + + @override + String get expand => 'Expand'; + + @override + String get collapse => 'Collapse'; + + @override + String get pageTitleRecipients => 'Recipient address book'; + + @override + String get actionAddNew => 'Add new'; + + @override + String get colDataOwner => 'Data owner'; + + @override + String get colAvatar => 'Avatar'; + + @override + String get colName => 'Name'; + + @override + String get colEmail => 'Email'; + + @override + String get colStatus => 'Status'; + + @override + String get statusReady => 'Ready'; + + @override + String get statusRegistered => 'Registered'; + + @override + String get statusNotRegistered => 'Not registered'; + + @override + String get typeInternal => 'Managed by me'; + + @override + String get typeExternal => 'Self‑managed'; + + @override + String get searchHint => 'Search recipients'; + + @override + String get colActions => 'Actions'; + + @override + String get menuEdit => 'Edit'; + + @override + String get menuSendPayout => 'Send payout'; + + @override + String get tooltipRowActions => 'More actions'; + + @override + String get accountSettings => 'Account Settings'; + + @override + String get accountNameUpdateError => 'Failed to update account name'; + + @override + String get settingsSuccessfullyUpdated => 'Settings successfully updated'; + + @override + String get language => 'Language'; + + @override + String get failedToUpdateLanguage => 'Failed to update language'; + + @override + String get settingsImageUpdateError => 'Couldn\'t update the image'; + + @override + String get settingsImageTitle => 'Image'; + + @override + String get settingsImageHint => 'Tap to change the image'; + + @override + String get accountName => 'Name'; + + @override + String get accountNameHint => 'Specify your name'; + + @override + String get avatar => 'Profile photo'; + + @override + String get avatarHint => 'Tap to update'; + + @override + String get avatarUpdateError => 'Failed to update profile photo'; + + @override + String get settings => 'Settings'; + + @override + String get notSet => 'not set'; + + @override + String get search => 'Search...'; + + @override + String get ok => 'Ok'; + + @override + String get cancel => 'Cancel'; + + @override + String get confirm => 'Confirm'; + + @override + String get back => 'Back'; + + @override + String get operationfryTitle => 'Operation history'; + + @override + String get filters => 'Filters'; + + @override + String get period => 'Period'; + + @override + String get selectPeriod => 'Select period'; + + @override + String get apply => 'Apply'; + + @override + String status(String status) { + return '$status'; + } + + @override + String get operationStatusSuccessful => 'Successful'; + + @override + String get operationStatusPending => 'Pending'; + + @override + String get operationStatusUnsuccessful => 'Unsuccessful'; + + @override + String get statusColumn => 'Status'; + + @override + String get fileNameColumn => 'File name'; + + @override + String get amountColumn => 'Amount'; + + @override + String get toAmountColumn => 'To amount'; + + @override + String get payIdColumn => 'Pay ID'; + + @override + String get cardNumberColumn => 'Card number'; + + @override + String get nameColumn => 'Name'; + + @override + String get dateColumn => 'Date'; + + @override + String get commentColumn => 'Comment'; + + @override + String get paymentConfigTitle => 'Where to receive money'; + + @override + String get paymentConfigSubtitle => + 'Add multiple methods and choose your primary one.'; + + @override + String get addPaymentMethod => 'Add payment method'; + + @override + String get makeMain => 'Make primary'; + + @override + String get advanced => 'Advanced'; + + @override + String get fallbackExplanation => + 'If the primary method is unavailable, we will try the next enabled one in the list.'; + + @override + String get delete => 'Delete'; + + @override + String get deletePaymentConfirmation => + 'Are you sure you want to delete this payment method?'; + + @override + String get edit => 'Edit'; + + @override + String get moreActions => 'More actions'; + + @override + String get noPayouts => 'No Payouts'; + + @override + String get enterBankName => 'Enter bank name'; + + @override + String get paymentType => 'Payment Method Type'; + + @override + String get selectPaymentType => 'Please select a payment method type'; + + @override + String get paymentTypeCard => 'Credit Card'; + + @override + String get paymentTypeBankAccount => 'Russian Bank Account'; + + @override + String get paymentTypeIban => 'IBAN'; + + @override + String get paymentTypeWallet => 'Wallet'; + + @override + String get cardNumber => 'Card Number'; + + @override + String get enterCardNumber => 'Enter the card number'; + + @override + String get cardholderName => 'Cardholder Name'; + + @override + String get iban => 'IBAN'; + + @override + String get enterIban => 'Enter IBAN'; + + @override + String get bic => 'BIC'; + + @override + String get bankName => 'Bank Name'; + + @override + String get accountHolder => 'Account Holder'; + + @override + String get enterAccountHolder => 'Enter account holder'; + + @override + String get enterBic => 'Enter BIC'; + + @override + String get walletId => 'Wallet ID'; + + @override + String get enterWalletId => 'Enter wallet ID'; + + @override + String get recipients => 'Recipients'; + + @override + String get recipientName => 'Recipient Name'; + + @override + String get enterRecipientName => 'Enter recipient name'; + + @override + String get inn => 'INN'; + + @override + String get enterInn => 'Enter INN'; + + @override + String get kpp => 'KPP'; + + @override + String get enterKpp => 'Enter KPP'; + + @override + String get accountNumber => 'Account Number'; + + @override + String get enterAccountNumber => 'Enter account number'; + + @override + String get correspondentAccount => 'Correspondent Account'; + + @override + String get enterCorrespondentAccount => 'Enter correspondent account'; + + @override + String get bik => 'BIK'; + + @override + String get enterBik => 'Enter BIK'; + + @override + String get add => 'Add'; + + @override + String get expiryDate => 'Expiry (MM/YY)'; + + @override + String get firstName => 'First Name'; + + @override + String get enterFirstName => 'Enter First Name'; + + @override + String get lastName => 'Last Name'; + + @override + String get enterLastName => 'Enter Last Name'; + + @override + String get sendSingle => 'Send single transaction'; + + @override + String get sendMultiple => 'Send multiple transactions'; + + @override + String get addFunds => 'Add Funds'; + + @override + String get close => 'Close'; + + @override + String get multiplePayout => 'Multiple Payout'; + + @override + String get howItWorks => 'How it works?'; + + @override + String get exampleTitle => 'File Format & Sample'; + + @override + String get downloadSampleCSV => 'Download sample.csv'; + + @override + String get tokenColumn => 'Token (required)'; + + @override + String get currency => 'Currency'; + + @override + String get amount => 'Amount'; + + @override + String get comment => 'Comment'; + + @override + String get uploadCSV => 'Upload your CSV'; + + @override + String get upload => 'Upload'; + + @override + String get hintUpload => 'Supported format: .CSV · Max size 1 MB'; + + @override + String get uploadHistory => 'Upload History'; + + @override + String get payout => 'Payout'; + + @override + String get sendTo => 'Send Payout To'; + + @override + String get send => 'Send Payout'; + + @override + String get recipientPaysFee => 'Recipient pays the fee'; + + @override + String sentAmount(String amount) { + return 'Sent amount: \$$amount'; + } + + @override + String fee(String fee) { + return 'Fee: \$$fee'; + } + + @override + String recipientWillReceive(String amount) { + return 'Recipient will receive: \$$amount'; + } + + @override + String total(String total) { + return 'Total: \$$total'; + } + + @override + String get hideDetails => 'Hide Details'; + + @override + String get showDetails => 'Show Details'; + + @override + String get whereGetMoney => 'Source of funds for debit'; + + @override + String get details => 'Details'; + + @override + String get addRecipient => 'Add Recipient'; + + @override + String get editRecipient => 'Edit Recipient'; + + @override + String get saveRecipient => 'Save Recipient'; + + @override + String get choosePaymentMethod => 'Payment Methods (choose at least 1)'; + + @override + String get recipientFormRule => + 'Recipient must have at least one payment method'; + + @override + String get allStatus => 'All'; + + @override + String get readyStatus => 'Ready'; + + @override + String get registeredStatus => 'Registered'; + + @override + String get notRegisteredStatus => 'Not registered'; + + @override + String get noRecipientSelected => 'No recipient selected'; + + @override + String get companyName => 'Name of your company'; + + @override + String get companynameRequired => 'Company name required'; + + @override + String get errorSignUp => 'Error occured while signing up, try again later'; + + @override + String get companyDescription => 'Company Description'; + + @override + String get companyDescriptionHint => + 'Describe any of the fields of the Company\'s business'; + + @override + String get optional => 'optional'; +} diff --git a/frontend/pweb/lib/generated/i18n/app_localizations_ru.dart b/frontend/pweb/lib/generated/i18n/app_localizations_ru.dart new file mode 100644 index 0000000..caf30fe --- /dev/null +++ b/frontend/pweb/lib/generated/i18n/app_localizations_ru.dart @@ -0,0 +1,782 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get login => 'Войти'; + + @override + String get logout => 'Выйти'; + + @override + String get profile => 'Профиль'; + + @override + String get signup => 'Регистрация'; + + @override + String get username => 'Email'; + + @override + String get usernameHint => 'email@example.com'; + + @override + String get usernameErrorInvalid => + 'Укажите действительный адрес электронной почты'; + + @override + String usernameUnknownTLD(Object domain) { + return 'Домен .$domain неизвестен, пожалуйста, проверьте его'; + } + + @override + String get password => 'Пароль'; + + @override + String get confirmPassword => 'Подтвердите пароль'; + + @override + String get passwordValidationRuleDigit => 'содержит цифру'; + + @override + String get passwordValidationRuleUpperCase => 'содержит заглавную букву'; + + @override + String get passwordValidationRuleLowerCase => 'содержит строчную букву'; + + @override + String get passwordValidationRuleSpecialCharacter => + 'содержит специальный символ'; + + @override + String passwordValidationRuleMinCharacters(Object charNum) { + return 'длина не менее $charNum символов'; + } + + @override + String get passwordsDoNotMatch => 'Пароли не совпадают'; + + @override + String passwordValidationError(Object matchesCriteria) { + return 'Убедитесь, что ваш пароль $matchesCriteria'; + } + + @override + String notificationError(Object error) { + return 'Произошла ошибка: $error'; + } + + @override + String loginUserNotFound(Object account) { + return 'Аккаунт $account не зарегистрирован в системе'; + } + + @override + String get loginPasswordIncorrect => + 'Ошибка авторизации, пожалуйста, проверьте пароль'; + + @override + String internalErrorOccurred(Object error) { + return 'Произошла внутренняя ошибка сервера: $error, мы уже знаем о ней и усердно работаем над исправлением'; + } + + @override + String get noErrorInformation => + 'Произошла ошибка, но у нас нет информации о ней. Мы уже расследуем этот вопрос'; + + @override + String get yourName => 'Ваше имя'; + + @override + String get nameHint => 'Иван Иванов'; + + @override + String get errorPageNotFoundTitle => 'Страница не найдена'; + + @override + String get errorPageNotFoundMessage => + 'Упс! Мы не смогли найти эту страницу.'; + + @override + String get errorPageNotFoundHint => + 'Запрашиваемая страница не существует или была перемещена. Пожалуйста, проверьте URL или вернитесь на главную страницу.'; + + @override + String get errorUnknown => 'Произошла неизвестная ошибка'; + + @override + String get unknown => 'неизвестно'; + + @override + String get goToLogin => 'Перейти к входу'; + + @override + String get goBack => 'Назад'; + + @override + String get goToMainPage => 'На главную'; + + @override + String get goToSignUp => 'Перейти к регистрации'; + + @override + String signupError(Object error) { + return 'Не удалось зарегистрироваться: $error'; + } + + @override + String signupSuccess(Object email) { + return 'Письмо с подтверждением email отправлено на $email. Пожалуйста, откройте его и перейдите по ссылке для активации вашего аккаунта.'; + } + + @override + String connectivityError(Object serverAddress) { + return 'Не удается связаться с сервером $serverAddress. Проверьте ваше интернет-соединение и попробуйте снова.'; + } + + @override + String get errorAccountExists => 'Account already exists'; + + @override + String get errorAccountNotVerified => + 'Ваш аккаунт еще не подтвержден. Пожалуйста, проверьте вашу электронную почту для завершения верификации'; + + @override + String get errorLoginUnauthorized => + 'Неверный логин или пароль. Пожалуйста, попробуйте снова'; + + @override + String get errorInternalError => + 'Произошла внутренняя ошибка. Мы в курсе проблемы и работаем над ее решением. Пожалуйста, попробуйте позже'; + + @override + String get errorVerificationTokenNotFound => + 'Аккаунт для верификации не найден. Зарегистрируйтесь снова'; + + @override + String get created => 'Создано'; + + @override + String get edited => 'Изменено'; + + @override + String get errorDataConflict => + 'Мы не можем обработать ваши данные, так как они содержат конфликтующую или противоречивую информацию.'; + + @override + String get errorAccessDenied => + 'У вас нет разрешения на доступ к этому ресурсу. Если вам нужен доступ, пожалуйста, обратитесь к администратору.'; + + @override + String get errorBrokenPayload => + 'Отправленные данные недействительны или неполны. Пожалуйста, проверьте введенные данные и попробуйте снова.'; + + @override + String get errorInvalidArgument => + 'Один или несколько аргументов недействительны. Проверьте введенные данные и попробуйте снова.'; + + @override + String get errorBrokenReference => + 'Ресурс, к которому вы пытаетесь получить доступ, не может быть найден. Возможно, он был перемещен или удален.'; + + @override + String get errorInvalidQueryParameter => + 'Один или несколько параметров запроса отсутствуют или указаны неверно. Проверьте их и попробуйте снова.'; + + @override + String get errorNotImplemented => + 'Эта функция еще недоступна. Пожалуйста, попробуйте позже или обратитесь в службу поддержки.'; + + @override + String get errorLicenseRequired => + 'Для выполнения этого действия требуется действующая лицензия. Пожалуйста, обратитесь к вашему администратору.'; + + @override + String get errorNotFound => + 'Мы не смогли найти запрошенный ресурс. Возможно, он был удален или временно недоступен.'; + + @override + String get errorNameMissing => 'Пожалуйста, укажите имя для продолжения.'; + + @override + String get errorEmailMissing => + 'Пожалуйста, укажите адрес электронной почты для продолжения.'; + + @override + String get errorPasswordMissing => + 'Пожалуйста, укажите пароль для продолжения.'; + + @override + String get errorEmailNotRegistered => + 'Мы не нашли аккаунт, связанный с этим адресом электронной почты.'; + + @override + String get errorDuplicateEmail => + 'Этот адрес электронной почты уже используется. Попробуйте другой или восстановите пароль.'; + + @override + String get showDetailsAction => 'Показать детали'; + + @override + String get errorLogin => 'Ошибка входа'; + + @override + String get errorCreatingInvitation => 'Не удалось создать приглашение'; + + @override + String get footerCompanyName => 'Sibilla Solutions LTD'; + + @override + String get footerAddress => + '27, Pindarou Street, Alpha Business Centre, Block B 7th Floor, 1060 Nicosia, Cyprus'; + + @override + String get footerSupport => 'Поддержка'; + + @override + String get footerEmail => 'Email TBD'; + + @override + String get footerPhoneLabel => 'Телефон'; + + @override + String get footerPhone => '+357 22 000 253'; + + @override + String get footerTermsOfService => 'Условия обслуживания'; + + @override + String get footerPrivacyPolicy => 'Политика конфиденциальности'; + + @override + String get footerCookiePolicy => 'Политика использования файлов cookie'; + + @override + String get navigationLogout => 'Выйти'; + + @override + String get dashboard => 'Дашборд'; + + @override + String get navigationUsersSettings => 'Пользователи'; + + @override + String get navigationRolesSettings => 'Роли'; + + @override + String get navigationPermissionsSettings => 'Разрешения'; + + @override + String get usersManagement => 'Управление пользователями'; + + @override + String get navigationOrganizationSettings => 'Настройки организации'; + + @override + String get navigationAccountSettings => 'Настройки профиля'; + + @override + String get twoFactorPrompt => + 'Введите 6-значный код, отправленный на ваше устройство'; + + @override + String get twoFactorResend => 'Не получили код? Отправить снова'; + + @override + String get twoFactorTitle => 'Двухфакторная аутентификация'; + + @override + String get twoFactorError => 'Неверный код. Пожалуйста, попробуйте снова.'; + + @override + String get payoutNavDashboard => 'Дашборд'; + + @override + String get payoutNavSendPayout => 'Отправить выплату'; + + @override + String get payoutNavRecipients => 'Получатели'; + + @override + String get payoutNavReports => 'Отчеты'; + + @override + String get payoutNavSettings => 'Настройки'; + + @override + String get payoutNavLogout => 'Выйти'; + + @override + String get payoutNavMethods => 'Выплаты'; + + @override + String get expand => 'Развернуть'; + + @override + String get collapse => 'Свернуть'; + + @override + String get pageTitleRecipients => 'Адресная книга получателей'; + + @override + String get actionAddNew => 'Добавить'; + + @override + String get colDataOwner => 'Владелец данных'; + + @override + String get colAvatar => 'Аватар'; + + @override + String get colName => 'Имя'; + + @override + String get colEmail => 'Email'; + + @override + String get colStatus => 'Статус'; + + @override + String get statusReady => 'Готов'; + + @override + String get statusRegistered => 'Зарегистрирован'; + + @override + String get statusNotRegistered => 'Не зарегистрирован'; + + @override + String get typeInternal => 'Управляется мной'; + + @override + String get typeExternal => 'Самоуправляемый'; + + @override + String get searchHint => 'Поиск получателей'; + + @override + String get colActions => 'Действия'; + + @override + String get menuEdit => 'Редактировать'; + + @override + String get menuSendPayout => 'Отправить выплату'; + + @override + String get tooltipRowActions => 'Другие действия'; + + @override + String get accountSettings => 'Настройки аккаунта'; + + @override + String get accountNameUpdateError => 'Не удалось обновить имя аккаунта'; + + @override + String get settingsSuccessfullyUpdated => 'Настройки успешно обновлены'; + + @override + String get language => 'Язык'; + + @override + String get failedToUpdateLanguage => 'Не удалось обновить язык'; + + @override + String get settingsImageUpdateError => 'Не удалось обновить изображение'; + + @override + String get settingsImageTitle => 'Изображение'; + + @override + String get settingsImageHint => 'Нажмите, чтобы изменить изображение'; + + @override + String get accountName => 'Имя'; + + @override + String get accountNameHint => 'Укажите ваше имя'; + + @override + String get avatar => 'Фото профиля'; + + @override + String get avatarHint => 'Нажмите для обновления'; + + @override + String get avatarUpdateError => 'Не удалось обновить фото профиля'; + + @override + String get settings => 'Настройки'; + + @override + String get notSet => 'не задано'; + + @override + String get search => 'Поиск...'; + + @override + String get ok => 'Ок'; + + @override + String get cancel => 'Отмена'; + + @override + String get confirm => 'Подтвердить'; + + @override + String get back => 'Назад'; + + @override + String get operationfryTitle => 'История операций'; + + @override + String get filters => 'Фильтры'; + + @override + String get period => 'Период'; + + @override + String get selectPeriod => 'Выберите период'; + + @override + String get apply => 'Применить'; + + @override + String status(String status) { + return '$status'; + } + + @override + String get operationStatusSuccessful => 'Успешно'; + + @override + String get operationStatusPending => 'В ожидании'; + + @override + String get operationStatusUnsuccessful => 'Неуспешно'; + + @override + String get statusColumn => 'Статус'; + + @override + String get fileNameColumn => 'Имя файла'; + + @override + String get amountColumn => 'Сумма'; + + @override + String get toAmountColumn => 'На сумму'; + + @override + String get payIdColumn => 'Pay ID'; + + @override + String get cardNumberColumn => 'Номер карты'; + + @override + String get nameColumn => 'Имя'; + + @override + String get dateColumn => 'Дата'; + + @override + String get commentColumn => 'Комментарий'; + + @override + String get paymentConfigTitle => 'Куда получать деньги'; + + @override + String get paymentConfigSubtitle => + 'Добавьте несколько методов и выберите основной.'; + + @override + String get addPaymentMethod => 'Добавить способ оплаты'; + + @override + String get makeMain => 'Сделать основным'; + + @override + String get advanced => 'Дополнительно'; + + @override + String get fallbackExplanation => + 'Если основной метод недоступен, мы попробуем следующий включенный метод в списке.'; + + @override + String get delete => 'Удалить'; + + @override + String get deletePaymentConfirmation => + 'Вы уверены, что хотите удалить этот способ оплаты?'; + + @override + String get edit => 'Редактировать'; + + @override + String get moreActions => 'Еще действия'; + + @override + String get noPayouts => 'Нет выплат'; + + @override + String get enterBankName => 'Введите название банка'; + + @override + String get paymentType => 'Тип способа оплаты'; + + @override + String get selectPaymentType => 'Пожалуйста, выберите тип способа оплаты'; + + @override + String get paymentTypeCard => 'Кредитная карта'; + + @override + String get paymentTypeBankAccount => 'Российский банковский счет'; + + @override + String get paymentTypeIban => 'IBAN'; + + @override + String get paymentTypeWallet => 'Кошелек'; + + @override + String get cardNumber => 'Номер карты'; + + @override + String get enterCardNumber => 'Введите номер карты'; + + @override + String get cardholderName => 'Имя держателя карты'; + + @override + String get iban => 'IBAN'; + + @override + String get enterIban => 'Введите IBAN'; + + @override + String get bic => 'BIC'; + + @override + String get bankName => 'Название банка'; + + @override + String get accountHolder => 'Владелец счета'; + + @override + String get enterAccountHolder => 'Введите владельца счета'; + + @override + String get enterBic => 'Введите BIC'; + + @override + String get walletId => 'ID кошелька'; + + @override + String get enterWalletId => 'Введите ID кошелька'; + + @override + String get recipients => 'Получатели'; + + @override + String get recipientName => 'Имя получателя'; + + @override + String get enterRecipientName => 'Введите имя получателя'; + + @override + String get inn => 'ИНН'; + + @override + String get enterInn => 'Введите ИНН'; + + @override + String get kpp => 'КПП'; + + @override + String get enterKpp => 'Введите КПП'; + + @override + String get accountNumber => 'Номер счета'; + + @override + String get enterAccountNumber => 'Введите номер счета'; + + @override + String get correspondentAccount => 'Корреспондентский счет'; + + @override + String get enterCorrespondentAccount => 'Введите корреспондентский счет'; + + @override + String get bik => 'БИК'; + + @override + String get enterBik => 'Введите БИК'; + + @override + String get add => 'Добавить'; + + @override + String get expiryDate => 'Срок действия (ММ/ГГ)'; + + @override + String get firstName => 'Имя'; + + @override + String get enterFirstName => 'Введите имя'; + + @override + String get lastName => 'Фамилия'; + + @override + String get enterLastName => 'Введите фамилию'; + + @override + String get sendSingle => 'Отправить одну транзакцию'; + + @override + String get sendMultiple => 'Отправить несколько транзакций'; + + @override + String get addFunds => 'Пополнить счет'; + + @override + String get close => 'Закрыть'; + + @override + String get multiplePayout => 'Множественная выплата'; + + @override + String get howItWorks => 'Как это работает?'; + + @override + String get exampleTitle => 'Формат файла и образец'; + + @override + String get downloadSampleCSV => 'Скачать sample.csv'; + + @override + String get tokenColumn => 'Токен (обязательно)'; + + @override + String get currency => 'Валюта'; + + @override + String get amount => 'Сумма'; + + @override + String get comment => 'Комментарий'; + + @override + String get uploadCSV => 'Загрузите ваш CSV'; + + @override + String get upload => 'Загрузить'; + + @override + String get hintUpload => 'Поддерживаемый формат: .CSV · Макс. размер 1 МБ'; + + @override + String get uploadHistory => 'История загрузок'; + + @override + String get payout => 'Выплата'; + + @override + String get sendTo => 'Отправить выплату'; + + @override + String get send => 'Отправить выплату'; + + @override + String get recipientPaysFee => 'Получатель оплачивает комиссию'; + + @override + String sentAmount(String amount) { + return 'Отправленная сумма: \$$amount'; + } + + @override + String fee(String fee) { + return 'Комиссия: \$$fee'; + } + + @override + String recipientWillReceive(String amount) { + return 'Получатель получит: \$$amount'; + } + + @override + String total(String total) { + return 'Итого: \$$total'; + } + + @override + String get hideDetails => 'Скрыть детали'; + + @override + String get showDetails => 'Показать детали'; + + @override + String get whereGetMoney => 'Источник средств для списания'; + + @override + String get details => 'Детали'; + + @override + String get addRecipient => 'Добавить получателя'; + + @override + String get editRecipient => 'Редактировать получателя'; + + @override + String get saveRecipient => 'Сохранить получателя'; + + @override + String get choosePaymentMethod => 'Способы оплаты (выберите хотя бы 1)'; + + @override + String get recipientFormRule => + 'Получатель должен иметь хотя бы один способ оплаты'; + + @override + String get allStatus => 'Все'; + + @override + String get readyStatus => 'Готов'; + + @override + String get registeredStatus => 'Зарегистрирован'; + + @override + String get notRegisteredStatus => 'Не зарегистрирован'; + + @override + String get noRecipientSelected => 'Получатель не выбран'; + + @override + String get companyName => 'Name of your company'; + + @override + String get companynameRequired => 'Company name required'; + + @override + String get errorSignUp => 'Error occured while signing up, try again later'; + + @override + String get companyDescription => 'Company Description'; + + @override + String get companyDescriptionHint => + 'Describe any of the fields of the Company\'s business'; + + @override + String get optional => 'optional'; +} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb new file mode 100644 index 0000000..b152d02 --- /dev/null +++ b/frontend/pweb/lib/l10n/en.arb @@ -0,0 +1,435 @@ +{ + "@@locale": "en", + "login": "Login", + "logout": "Logout", + "profile": "Profile", + "signup": "Sign up", + "username": "Email", + "usernameHint": "email@example.com", + "usernameErrorInvalid": "Provide a valid email address", + "usernameUnknownTLD": "Domain .{domain} is not known, please, check it", + "password": "Password", + "confirmPassword": "Confirm password", + "passwordValidationRuleDigit": "has digit", + "passwordValidationRuleUpperCase": "has uppercase letter", + "passwordValidationRuleLowerCase": "has lowercase letter", + "passwordValidationRuleSpecialCharacter": "has special character letter", + "passwordValidationRuleMinCharacters": "is {charNum} characters long at least", + "passwordsDoNotMatch": "Passwords do not match", + "passwordValidationError": "Check that your password {matchesCriteria}", + "notificationError": "Error occurred: {error}", + "loginUserNotFound": "Account {account} has not been registered in the system", + "loginPasswordIncorrect": "Authorization failed, please check your password", + "internalErrorOccurred": "An internal server error occurred: {error}, we already know about it and working hard to fix it", + "noErrorInformation": "Some error occurred, but we have not error information. We are already investigating the issue", + "yourName": "Your name", + "nameHint": "John Doe", + "errorPageNotFoundTitle": "Page Not Found", + "errorPageNotFoundMessage": "Oops! We couldn't find that page.", + "errorPageNotFoundHint": "The page you're looking for doesn't exist or has been moved. Please check the URL or return to the home page.", + "errorUnknown": "Unknown error occurred", + "unknown": "unknown", + "goToLogin": "Go to Login", + "goBack": "Go Back", + "goToMainPage": "Go to Main Page", + "goToSignUp": "Go to Sign Up", + "signupError": "Failed to signup: {error}", + "signupSuccess": "Email confirmation message has been sent to {email}. Please, open it and click link to activate your account.", + "connectivityError": "Cannot reach the server at {serverAddress}. Check your network and try again.", + "errorAccountExists": "Account already exists", + "errorAccountNotVerified": "Your account hasn't been verified yet. Please check your email to complete the verification", + "errorLoginUnauthorized": "Login or password is incorrect. Please try again", + "errorInternalError": "An internal error occurred. We're aware of the issue and working to resolve it. Please try again later", + "errorVerificationTokenNotFound": "Account for verification not found. Sign up again", + "created": "Created", + "edited": "Edited", + "errorDataConflict": "We can’t process your data because it has conflicting or contradictory information.", + "errorAccessDenied": "You do not have permission to access this resource. If you need access, please contact an administrator.", + "errorBrokenPayload": "The data you sent is invalid or incomplete. Please check your submission and try again.", + "errorInvalidArgument": "One or more arguments are invalid. Verify your input and try again.", + "errorBrokenReference": "The resource you're trying to access could not be referenced. It may have been moved or deleted.", + "errorInvalidQueryParameter": "One or more query parameters are missing or incorrect. Check them and try again.", + "errorNotImplemented": "This feature is not yet available. Please try again later or contact support.", + "errorLicenseRequired": "A valid license is required to perform this action. Please contact your administrator.", + "errorNotFound": "We couldn't find the resource you requested. It may have been removed or is temporarily unavailable.", + "errorNameMissing": "Please provide a name before continuing.", + "errorEmailMissing": "Please provide an email address before continuing.", + "errorPasswordMissing": "Please provide a password before continuing.", + "errorEmailNotRegistered": "We could not find an account associated with that email address.", + "errorDuplicateEmail": "This email address is already in use. Try another one or reset your password.", + "showDetailsAction": "Show Details", + "errorLogin": "Error logging in", + "errorCreatingInvitation": "Failed to create invitaiton", + "@errorCreatingInvitation": { + "description": "Error message displayed when invitation creation fails" + }, + "footerCompanyName": "Sibilla Solutions LTD", + "footerAddress": "27, Pindarou Street, Alpha Business Centre, Block B 7th Floor, 1060 Nicosia, Cyprus", + "footerSupport": "Support", + "footerEmail": "Email TBD", + "footerPhoneLabel": "Phone", + "footerPhone": "+357 22 000 253", + "footerTermsOfService": "Terms of Service", + "footerPrivacyPolicy": "Privacy Policy", + "footerCookiePolicy": "Cookie Policy", + "navigationLogout": "Logout", + "dashboard": "Dashboard", + "navigationUsersSettings": "Users", + "navigationRolesSettings": "Roles", + "navigationPermissionsSettings": "Permissions", + "usersManagement": "User Management", + "navigationOrganizationSettings": "Organization settings", + "navigationAccountSettings": "Profile settings", + "twoFactorPrompt": "Enter the 6-digit code we sent to your device", + "twoFactorResend": "Didn’t receive a code? Resend", + "twoFactorTitle": "Two-Factor Authentication", + "twoFactorError": "Invalid code. Please try again.", + "payoutNavDashboard": "Dashboard", + "payoutNavSendPayout": "Send payout", + "payoutNavRecipients": "Recipients", + "payoutNavReports": "Reports", + "payoutNavSettings": "Settings", + "payoutNavLogout": "Logout", + "payoutNavMethods": "Payouts", + "expand": "Expand", + "collapse": "Collapse", + "pageTitleRecipients": "Recipient address book", + "@pageTitleRecipients": { + "description": "Title of the recipient address book page", + "type": "text" + }, + + "actionAddNew": "Add new", + "@actionAddNew": { + "description": "Tooltip and button label to add a new recipient" + }, + + "colDataOwner": "Data owner", + "@colDataOwner": { + "description": "Column header for who manages the payout data" + }, + + "colAvatar": "Avatar", + "@colAvatar": { + "description": "Column header for recipient avatar" + }, + + "colName": "Name", + "@colName": { + "description": "Column header for recipient name" + }, + + "colEmail": "Email", + "@colEmail": { + "description": "Column header for recipient email address" + }, + + "colStatus": "Status", + "@colStatus": { + "description": "Column header for payout readiness status" + }, + + "statusReady": "Ready", + "@statusReady": { + "description": "Status indicating payouts can be sent immediately" + }, + + "statusRegistered": "Registered", + "@statusRegistered": { + "description": "Status indicating recipient is registered but not yet fully ready" + }, + + "statusNotRegistered": "Not registered", + "@statusNotRegistered": { + "description": "Status indicating recipient has not completed registration" + }, + + "typeInternal": "Managed by me", + "@typeInternal": { + "description": "Label for recipients whose payout data is managed internally by the user/company" + }, + + "typeExternal": "Self‑managed", + "@typeExternal": { + "description": "Label for recipients who manage their own payout data" + }, + + "searchHint": "Search recipients", + "colActions": "Actions", + "menuEdit": "Edit", + "menuSendPayout": "Send payout", + "tooltipRowActions": "More actions", + "accountSettings": "Account Settings", + "accountNameUpdateError": "Failed to update account name", + "settingsSuccessfullyUpdated": "Settings successfully updated", + "language": "Language", + "failedToUpdateLanguage": "Failed to update language", + "settingsImageUpdateError": "Couldn't update the image", + "settingsImageTitle": "Image", + "settingsImageHint": "Tap to change the image", + "accountName": "Name", + "accountNameHint": "Specify your name", + "avatar": "Profile photo", + "avatarHint": "Tap to update", + "avatarUpdateError": "Failed to update profile photo", + "settings": "Settings", + "notSet": "not set", + "search": "Search...", + "ok": "Ok", + "cancel": "Cancel", + "confirm": "Confirm", + "back": "Back", + + "operationfryTitle": "Operation history", + "@operationfryTitle": { + "description": "Title of the operation history page" + }, + + "filters": "Filters", + "@filters": { + "description": "Label for the filters expansion panel" + }, + + "period": "Period", + "@period": { + "description": "Label for the date‐range filter" + }, + + "selectPeriod": "Select period", + "@selectPeriod": { + "description": "Placeholder when no period is selected" + }, + + "apply": "Apply", + "@apply": { + "description": "Button text to apply the filters" + }, + + "status": "{status}", + "@status": { + "description": "Template for a single status filter chip", + "placeholders": { + "status": { + "type": "String", + "example": "Successful" + } + } + }, + + "operationStatusSuccessful": "Successful", + "@operationStatusSuccessful": { + "description": "Status indicating the operation succeeded" + }, + + "operationStatusPending": "Pending", + "@operationStatusPending": { + "description": "Status indicating the operation is pending" + }, + + "operationStatusUnsuccessful": "Unsuccessful", + "@operationStatusUnsuccessful": { + "description": "Status indicating the operation failed" + }, + + "statusColumn": "Status", + "@statusColumn": { + "description": "Table column header for status" + }, + + "fileNameColumn": "File name", + "@fileNameColumn": { + "description": "Table column header for file name" + }, + + "amountColumn": "Amount", + "@amountColumn": { + "description": "Table column header for the original amount" + }, + + "toAmountColumn": "To amount", + "@toAmountColumn": { + "description": "Table column header for the converted amount" + }, + + "payIdColumn": "Pay ID", + "@payIdColumn": { + "description": "Table column header for the payment ID" + }, + + "cardNumberColumn": "Card number", + "@cardNumberColumn": { + "description": "Table column header for the masked card number" + }, + + "nameColumn": "Name", + "@nameColumn": { + "description": "Table column header for recipient name" + }, + + "dateColumn": "Date", + "@dateColumn": { + "description": "Table column header for the date/time" + }, + + "commentColumn": "Comment", + "@commentColumn": { + "description": "Table column header for any comment" + }, + "paymentConfigTitle": "Where to receive money", + "paymentConfigSubtitle": "Add multiple methods and choose your primary one.", + "addPaymentMethod": "Add payment method", + "makeMain": "Make primary", + "advanced": "Advanced", + "fallbackExplanation": "If the primary method is unavailable, we will try the next enabled one in the list.", + "delete": "Delete", + "@delete": { + "description": "Button label to delete a payment method" + }, + + "deletePaymentConfirmation": "Are you sure you want to delete this payment method?", + "@deletePaymentConfirmation": { + "description": "Confirmation dialog message shown before a payment method is removed" + }, + + "edit": "Edit", + "@edit": { + "description": "Button label to edit a payment method" + }, + + "moreActions": "More actions", + "@moreActions": { + "description": "Tooltip for an overflow menu button that reveals extra actions for a payment method" + }, + "noPayouts": "No Payouts", + + "enterBankName": "Enter bank name", + + "paymentType": "Payment Method Type", + "selectPaymentType": "Please select a payment method type", + + "paymentTypeCard": "Credit Card", + "paymentTypeBankAccount": "Russian Bank Account", + "paymentTypeIban": "IBAN", + "paymentTypeWallet": "Wallet", + + "cardNumber": "Card Number", + "enterCardNumber": "Enter the card number", + "cardholderName": "Cardholder Name", + + "iban": "IBAN", + "enterIban": "Enter IBAN", + "bic": "BIC", + "bankName": "Bank Name", + "accountHolder": "Account Holder", + "enterAccountHolder": "Enter account holder", + "enterBic": "Enter BIC", + + "walletId": "Wallet ID", + "enterWalletId": "Enter wallet ID", + + "recipients": "Recipients", + "recipientName": "Recipient Name", + "enterRecipientName": "Enter recipient name", + "inn": "INN", + "enterInn": "Enter INN", + "kpp": "KPP", + "enterKpp": "Enter KPP", + "accountNumber": "Account Number", + "enterAccountNumber": "Enter account number", + "correspondentAccount": "Correspondent Account", + "enterCorrespondentAccount": "Enter correspondent account", + "bik": "BIK", + "enterBik": "Enter BIK", + "add": "Add", + "expiryDate": "Expiry (MM/YY)", + "firstName": "First Name", + "enterFirstName": "Enter First Name", + "lastName": "Last Name", + "enterLastName": "Enter Last Name", + "sendSingle": "Send single transaction", + "sendMultiple": "Send multiple transactions", + "addFunds": "Add Funds", + "close": "Close", + "multiplePayout": "Multiple Payout", + "howItWorks": "How it works?", + "exampleTitle": "File Format & Sample", + "downloadSampleCSV": "Download sample.csv", + "tokenColumn": "Token (required)", + "currency": "Currency", + "amount": "Amount", + "comment": "Comment", + "uploadCSV": "Upload your CSV", + "upload": "Upload", + "hintUpload": "Supported format: .CSV · Max size 1 MB", + "uploadHistory": "Upload History", + "payout": "Payout", + "sendTo": "Send Payout To", + "send": "Send Payout", + "recipientPaysFee": "Recipient pays the fee", + + "sentAmount": "Sent amount: ${amount}", + "@sentAmount": { + "description": "Label showing the amount sent", + "placeholders": { + "amount": { + "type": "String" + } + } + }, + + "fee": "Fee: ${fee}", + "@fee": { + "description": "Label showing the transaction fee", + "placeholders": { + "fee": { + "type": "String" + } + } + }, + + "recipientWillReceive": "Recipient will receive: ${amount}", + "@recipientWillReceive": { + "description": "Label showing how much the recipient will receive", + "placeholders": { + "amount": { + "type": "String" + } + } + }, + + "total": "Total: ${total}", + "@total": { + "description": "Label showing the total amount of the transaction", + "placeholders": { + "total": { + "type": "String" + } + } + }, + "hideDetails": "Hide Details", + "showDetails": "Show Details", + "whereGetMoney": "Source of funds for debit", + "details": "Details", + + "addRecipient": "Add Recipient", + "editRecipient": "Edit Recipient", + "saveRecipient": "Save Recipient", + + "choosePaymentMethod": "Payment Methods (choose at least 1)", + "recipientFormRule": "Recipient must have at least one payment method", + + "allStatus": "All", + "readyStatus": "Ready", + "registeredStatus": "Registered", + "notRegisteredStatus": "Not registered", + + "noRecipientSelected": "No recipient selected", + + "companyName": "Name of your company", + "companynameRequired": "Company name required", + + "errorSignUp": "Error occured while signing up, try again later", + "companyDescription": "Company Description", + "companyDescriptionHint": "Describe any of the fields of the Company's business", + "optional": "optional" +} \ No newline at end of file diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb new file mode 100644 index 0000000..def1290 --- /dev/null +++ b/frontend/pweb/lib/l10n/ru.arb @@ -0,0 +1,426 @@ +{ + "@@locale": "ru", + "login": "Войти", + "logout": "Выйти", + "profile": "Профиль", + "signup": "Регистрация", + "username": "Email", + "usernameHint": "email@example.com", + "usernameErrorInvalid": "Укажите действительный адрес электронной почты", + "usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его", + "password": "Пароль", + "confirmPassword": "Подтвердите пароль", + "passwordValidationRuleDigit": "содержит цифру", + "passwordValidationRuleUpperCase": "содержит заглавную букву", + "passwordValidationRuleLowerCase": "содержит строчную букву", + "passwordValidationRuleSpecialCharacter": "содержит специальный символ", + "passwordValidationRuleMinCharacters": "длина не менее {charNum} символов", + "passwordsDoNotMatch": "Пароли не совпадают", + "passwordValidationError": "Убедитесь, что ваш пароль {matchesCriteria}", + "notificationError": "Произошла ошибка: {error}", + "loginUserNotFound": "Аккаунт {account} не зарегистрирован в системе", + "loginPasswordIncorrect": "Ошибка авторизации, пожалуйста, проверьте пароль", + "internalErrorOccurred": "Произошла внутренняя ошибка сервера: {error}, мы уже знаем о ней и усердно работаем над исправлением", + "noErrorInformation": "Произошла ошибка, но у нас нет информации о ней. Мы уже расследуем этот вопрос", + "yourName": "Ваше имя", + "nameHint": "Иван Иванов", + "errorPageNotFoundTitle": "Страница не найдена", + "errorPageNotFoundMessage": "Упс! Мы не смогли найти эту страницу.", + "errorPageNotFoundHint": "Запрашиваемая страница не существует или была перемещена. Пожалуйста, проверьте URL или вернитесь на главную страницу.", + "errorUnknown": "Произошла неизвестная ошибка", + "unknown": "неизвестно", + "goToLogin": "Перейти к входу", + "goBack": "Назад", + "goToMainPage": "На главную", + "goToSignUp": "Перейти к регистрации", + "signupError": "Не удалось зарегистрироваться: {error}", + "signupSuccess": "Письмо с подтверждением email отправлено на {email}. Пожалуйста, откройте его и перейдите по ссылке для активации вашего аккаунта.", + "connectivityError": "Не удается связаться с сервером {serverAddress}. Проверьте ваше интернет-соединение и попробуйте снова.", + "errorAccountNotVerified": "Ваш аккаунт еще не подтвержден. Пожалуйста, проверьте вашу электронную почту для завершения верификации", + "errorLoginUnauthorized": "Неверный логин или пароль. Пожалуйста, попробуйте снова", + "errorInternalError": "Произошла внутренняя ошибка. Мы в курсе проблемы и работаем над ее решением. Пожалуйста, попробуйте позже", + "errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова", + "created": "Создано", + "edited": "Изменено", + "errorDataConflict": "Мы не можем обработать ваши данные, так как они содержат конфликтующую или противоречивую информацию.", + "errorAccessDenied": "У вас нет разрешения на доступ к этому ресурсу. Если вам нужен доступ, пожалуйста, обратитесь к администратору.", + "errorBrokenPayload": "Отправленные данные недействительны или неполны. Пожалуйста, проверьте введенные данные и попробуйте снова.", + "errorInvalidArgument": "Один или несколько аргументов недействительны. Проверьте введенные данные и попробуйте снова.", + "errorBrokenReference": "Ресурс, к которому вы пытаетесь получить доступ, не может быть найден. Возможно, он был перемещен или удален.", + "errorInvalidQueryParameter": "Один или несколько параметров запроса отсутствуют или указаны неверно. Проверьте их и попробуйте снова.", + "errorNotImplemented": "Эта функция еще недоступна. Пожалуйста, попробуйте позже или обратитесь в службу поддержки.", + "errorLicenseRequired": "Для выполнения этого действия требуется действующая лицензия. Пожалуйста, обратитесь к вашему администратору.", + "errorNotFound": "Мы не смогли найти запрошенный ресурс. Возможно, он был удален или временно недоступен.", + "errorNameMissing": "Пожалуйста, укажите имя для продолжения.", + "errorEmailMissing": "Пожалуйста, укажите адрес электронной почты для продолжения.", + "errorPasswordMissing": "Пожалуйста, укажите пароль для продолжения.", + "errorEmailNotRegistered": "Мы не нашли аккаунт, связанный с этим адресом электронной почты.", + "errorDuplicateEmail": "Этот адрес электронной почты уже используется. Попробуйте другой или восстановите пароль.", + "showDetailsAction": "Показать детали", + "errorLogin": "Ошибка входа", + "errorCreatingInvitation": "Не удалось создать приглашение", + "@errorCreatingInvitation": { + "description": "Сообщение об ошибке, отображаемое при неудачном создании приглашения" + }, + "footerCompanyName": "Sibilla Solutions LTD", + "footerAddress": "27, Pindarou Street, Alpha Business Centre, Block B 7th Floor, 1060 Nicosia, Cyprus", + "footerSupport": "Поддержка", + "footerEmail": "Email TBD", + "footerPhoneLabel": "Телефон", + "footerPhone": "+357 22 000 253", + "footerTermsOfService": "Условия обслуживания", + "footerPrivacyPolicy": "Политика конфиденциальности", + "footerCookiePolicy": "Политика использования файлов cookie", + "navigationLogout": "Выйти", + "dashboard": "Дашборд", + "navigationUsersSettings": "Пользователи", + "navigationRolesSettings": "Роли", + "navigationPermissionsSettings": "Разрешения", + "usersManagement": "Управление пользователями", + "navigationOrganizationSettings": "Настройки организации", + "navigationAccountSettings": "Настройки профиля", + "twoFactorPrompt": "Введите 6-значный код, отправленный на ваше устройство", + "twoFactorResend": "Не получили код? Отправить снова", + "twoFactorTitle": "Двухфакторная аутентификация", + "twoFactorError": "Неверный код. Пожалуйста, попробуйте снова.", + "payoutNavDashboard": "Дашборд", + "payoutNavSendPayout": "Отправить выплату", + "payoutNavRecipients": "Получатели", + "payoutNavReports": "Отчеты", + "payoutNavSettings": "Настройки", + "payoutNavLogout": "Выйти", + "payoutNavMethods": "Выплаты", + "expand": "Развернуть", + "collapse": "Свернуть", + "pageTitleRecipients": "Адресная книга получателей", + "@pageTitleRecipients": { + "description": "Заголовок страницы адресной книги получателей", + "type": "text" + }, + + "actionAddNew": "Добавить", + "@actionAddNew": { + "description": "Подсказка и метка кнопки для добавления нового получателя" + }, + + "colDataOwner": "Владелец данных", + "@colDataOwner": { + "description": "Заголовок столбца для указания, кто управляет данными о выплатах" + }, + + "colAvatar": "Аватар", + "@colAvatar": { + "description": "Заголовок столбца для аватара получателя" + }, + + "colName": "Имя", + "@colName": { + "description": "Заголовок столбца для имени получателя" + }, + + "colEmail": "Email", + "@colEmail": { + "description": "Заголовок столбца для адреса электронной почты получателя" + }, + + "colStatus": "Статус", + "@colStatus": { + "description": "Заголовок столбца для статуса готовности к выплате" + }, + + "statusReady": "Готов", + "@statusReady": { + "description": "Статус, указывающий, что выплаты можно отправлять немедленно" + }, + + "statusRegistered": "Зарегистрирован", + "@statusRegistered": { + "description": "Статус, указывающий, что получатель зарегистрирован, но еще не полностью готов" + }, + + "statusNotRegistered": "Не зарегистрирован", + "@statusNotRegistered": { + "description": "Статус, указывающий, что получатель не завершил регистрацию" + }, + + "typeInternal": "Управляется мной", + "@typeInternal": { + "description": "Метка для получателей, чьи данные о выплатах управляются внутренне пользователем/компанией" + }, + + "typeExternal": "Самоуправляемый", + "@typeExternal": { + "description": "Метка для получателей, которые управляют своими данными о выплатах самостоятельно" + }, + + "searchHint": "Поиск получателей", + "colActions": "Действия", + "menuEdit": "Редактировать", + "menuSendPayout": "Отправить выплату", + "tooltipRowActions": "Другие действия", + "accountSettings": "Настройки аккаунта", + "accountNameUpdateError": "Не удалось обновить имя аккаунта", + "settingsSuccessfullyUpdated": "Настройки успешно обновлены", + "language": "Язык", + "failedToUpdateLanguage": "Не удалось обновить язык", + "settingsImageUpdateError": "Не удалось обновить изображение", + "settingsImageTitle": "Изображение", + "settingsImageHint": "Нажмите, чтобы изменить изображение", + "accountName": "Имя", + "accountNameHint": "Укажите ваше имя", + "avatar": "Фото профиля", + "avatarHint": "Нажмите для обновления", + "avatarUpdateError": "Не удалось обновить фото профиля", + "settings": "Настройки", + "notSet": "не задано", + "search": "Поиск...", + "ok": "Ок", + "cancel": "Отмена", + "confirm": "Подтвердить", + "back": "Назад", + + "operationfryTitle": "История операций", + "@operationfryTitle": { + "description": "Заголовок страницы истории операций" + }, + + "filters": "Фильтры", + "@filters": { + "description": "Метка для панели расширения фильтров" + }, + + "period": "Период", + "@period": { + "description": "Метка для фильтра по диапазону дат" + }, + + "selectPeriod": "Выберите период", + "@selectPeriod": { + "description": "Заполнитель, когда период не выбран" + }, + + "apply": "Применить", + "@apply": { + "description": "Текст кнопки для применения фильтров" + }, + + "status": "{status}", + "@status": { + "description": "Шаблон для одного чипа фильтра статуса", + "placeholders": { + "status": { + "type": "String", + "example": "Успешно" + } + } + }, + + "operationStatusSuccessful": "Успешно", + "@operationStatusSuccessful": { + "description": "Статус, указывающий на успешное выполнение операции" + }, + + "operationStatusPending": "В ожидании", + "@operationStatusPending": { + "description": "Статус, указывающий, что операция ожидает выполнения" + }, + + "operationStatusUnsuccessful": "Неуспешно", + "@operationStatusUnsuccessful": { + "description": "Статус, указывающий на сбой операции" + }, + + "statusColumn": "Статус", + "@statusColumn": { + "description": "Заголовок столбца таблицы для статуса" + }, + + "fileNameColumn": "Имя файла", + "@fileNameColumn": { + "description": "Заголовок столбца таблицы для имени файла" + }, + + "amountColumn": "Сумма", + "@amountColumn": { + "description": "Заголовок столбца таблицы для исходной суммы" + }, + + "toAmountColumn": "На сумму", + "@toAmountColumn": { + "description": "Заголовок столбца таблицы для конвертированной суммы" + }, + + "payIdColumn": "Pay ID", + "@payIdColumn": { + "description": "Заголовок столбца таблицы для идентификатора платежа" + }, + + "cardNumberColumn": "Номер карты", + "@cardNumberColumn": { + "description": "Заголовок столбца таблицы для замаскированного номера карты" + }, + + "nameColumn": "Имя", + "@nameColumn": { + "description": "Заголовок столбца таблицы для имени получателя" + }, + + "dateColumn": "Дата", + "@dateColumn": { + "description": "Заголовок столбца таблицы для даты/времени" + }, + + "commentColumn": "Комментарий", + "@commentColumn": { + "description": "Заголовок столбца таблицы для комментария" + }, + "paymentConfigTitle": "Куда получать деньги", + "paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.", + "addPaymentMethod": "Добавить способ оплаты", + "makeMain": "Сделать основным", + "advanced": "Дополнительно", + "fallbackExplanation": "Если основной метод недоступен, мы попробуем следующий включенный метод в списке.", + "delete": "Удалить", + "@delete": { + "description": "Метка кнопки для удаления способа оплаты" + }, + + "deletePaymentConfirmation": "Вы уверены, что хотите удалить этот способ оплаты?", + "@deletePaymentConfirmation": { + "description": "Сообщение диалога подтверждения, показываемое перед удалением способа оплаты" + }, + + "edit": "Редактировать", + "@edit": { + "description": "Метка кнопки для редактирования способа оплаты" + }, + + "moreActions": "Еще действия", + "@moreActions": { + "description": "Подсказка для кнопки меню с многоточием, открывающей дополнительные действия для способа оплаты" + }, + "noPayouts": "Нет выплат", + + "enterBankName": "Введите название банка", + + "paymentType": "Тип способа оплаты", + "selectPaymentType": "Пожалуйста, выберите тип способа оплаты", + + "paymentTypeCard": "Кредитная карта", + "paymentTypeBankAccount": "Российский банковский счет", + "paymentTypeIban": "IBAN", + "paymentTypeWallet": "Кошелек", + + "cardNumber": "Номер карты", + "enterCardNumber": "Введите номер карты", + "cardholderName": "Имя держателя карты", + + "iban": "IBAN", + "enterIban": "Введите IBAN", + "bic": "BIC", + "bankName": "Название банка", + "accountHolder": "Владелец счета", + "enterAccountHolder": "Введите владельца счета", + "enterBic": "Введите BIC", + + "walletId": "ID кошелька", + "enterWalletId": "Введите ID кошелька", + + "recipients": "Получатели", + "recipientName": "Имя получателя", + "enterRecipientName": "Введите имя получателя", + "inn": "ИНН", + "enterInn": "Введите ИНН", + "kpp": "КПП", + "enterKpp": "Введите КПП", + "accountNumber": "Номер счета", + "enterAccountNumber": "Введите номер счета", + "correspondentAccount": "Корреспондентский счет", + "enterCorrespondentAccount": "Введите корреспондентский счет", + "bik": "БИК", + "enterBik": "Введите БИК", + "add": "Добавить", + "expiryDate": "Срок действия (ММ/ГГ)", + "firstName": "Имя", + "enterFirstName": "Введите имя", + "lastName": "Фамилия", + "enterLastName": "Введите фамилию", + "sendSingle": "Отправить одну транзакцию", + "sendMultiple": "Отправить несколько транзакций", + "addFunds": "Пополнить счет", + "close": "Закрыть", + "multiplePayout": "Множественная выплата", + "howItWorks": "Как это работает?", + "exampleTitle": "Формат файла и образец", + "downloadSampleCSV": "Скачать sample.csv", + "tokenColumn": "Токен (обязательно)", + "currency": "Валюта", + "amount": "Сумма", + "comment": "Комментарий", + "uploadCSV": "Загрузите ваш CSV", + "upload": "Загрузить", + "hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ", + "uploadHistory": "История загрузок", + "payout": "Выплата", + "sendTo": "Отправить выплату", + "send": "Отправить выплату", + "recipientPaysFee": "Получатель оплачивает комиссию", + + "sentAmount": "Отправленная сумма: ${amount}", + "@sentAmount": { + "description": "Метка, показывающая отправленную сумму", + "placeholders": { + "amount": { + "type": "String" + } + } + }, + + "fee": "Комиссия: ${fee}", + "@fee": { + "description": "Метка, показывающая комиссию за транзакцию", + "placeholders": { + "fee": { + "type": "String" + } + } + }, + + "recipientWillReceive": "Получатель получит: ${amount}", + "@recipientWillReceive": { + "description": "Метка, показывающая, сколько получит получатель", + "placeholders": { + "amount": { + "type": "String" + } + } + }, + + "total": "Итого: ${total}", + "@total": { + "description": "Метка, показывающая общую сумму транзакции", + "placeholders": { + "total": { + "type": "String" + } + } + }, + "hideDetails": "Скрыть детали", + "showDetails": "Показать детали", + "whereGetMoney": "Источник средств для списания", + "details": "Детали", + + "addRecipient": "Добавить получателя", + "editRecipient": "Редактировать получателя", + "saveRecipient": "Сохранить получателя", + + "choosePaymentMethod": "Способы оплаты (выберите хотя бы 1)", + "recipientFormRule": "Получатель должен иметь хотя бы один способ оплаты", + + "allStatus": "Все", + "readyStatus": "Готов", + "registeredStatus": "Зарегистрирован", + "notRegisteredStatus": "Не зарегистрирован", + + "noRecipientSelected": "Получатель не выбран" +} \ No newline at end of file diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart new file mode 100644 index 0000000..0ee7f60 --- /dev/null +++ b/frontend/pweb/lib/main.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +// ignore: depend_on_referenced_packages +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +import 'package:provider/provider.dart'; + +import 'package:logging/logging.dart'; + +import 'package:pshared/config/constants.dart'; +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/locale.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/pfe/provider.dart'; + +import 'package:pweb/app/app.dart'; +import 'package:pweb/app/timeago.dart'; +import 'package:pweb/providers/balance.dart'; +import 'package:pweb/providers/carousel.dart'; +import 'package:pweb/providers/mock_payment.dart'; +import 'package:pweb/providers/page_selector.dart'; +import 'package:pweb/providers/payment_methods.dart'; +import 'package:pweb/providers/recipient.dart'; +import 'package:pweb/providers/two_factor.dart'; +import 'package:pweb/providers/upload_history.dart'; +import 'package:pweb/providers/wallets.dart'; +import 'package:pweb/services/amplitude.dart'; +import 'package:pweb/services/auth.dart'; +import 'package:pweb/services/balance.dart'; +import 'package:pweb/services/payments/payment_methods.dart'; +import 'package:pweb/services/payments/upload_history.dart'; +import 'package:pweb/services/recipient/recipient.dart'; +import 'package:pweb/services/wallets.dart'; + + +void _setupLogging() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print('${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); + }); +} + +void main() async { + await Constants.initialize(); + await AmplitudeService.initialize(); + + + _setupLogging(); + setUrlStrategy(PathUrlStrategy()); + + initializeTimeagoLocales(); + + runApp( + MultiProvider( + providers: [ + Provider( + create: (_) => AuthenticationService(), + ), + ChangeNotifierProxyProvider( + create: (context) => TwoFactorProvider( + context.read(), + ), + update: (context, authService, previous) => TwoFactorProvider(authService), + ), + ChangeNotifierProvider(create: (_) => LocaleProvider(null)), + ChangeNotifierProvider(create: (_) => AccountProvider()), + ChangeNotifierProvider(create: (_) => OrganizationsProvider()), + ChangeNotifierProvider(create: (_) => PfeProvider()), + ChangeNotifierProvider(create: (_) => CarouselIndexProvider()), + + ChangeNotifierProvider( + create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(), + ), + ChangeNotifierProvider( + create: (_) => PaymentMethodsProvider(service: MockPaymentMethodsService())..loadMethods(), + ), + ChangeNotifierProvider( + create: (_) => WalletsProvider(MockWalletsService())..loadData(), + ), + ChangeNotifierProvider( + create: (_) => MockPaymentProvider(), + ), + ChangeNotifierProvider( + create: (_) => RecipientProvider(RecipientService())..loadRecipients(), + ), + ChangeNotifierProvider( + create: (context) { + final recipient = context.read(); + final wallets = context.read(); + return PageSelectorProvider( + recipientProvider: recipient, + walletsProvider: wallets, + ); + }, + ), + ChangeNotifierProvider( + create: (_) => BalanceProvider(MockBalanceService())..loadData(), + ), + ], + child: const PayApp(), + ), + ); +} diff --git a/frontend/pweb/lib/models/currency.dart b/frontend/pweb/lib/models/currency.dart new file mode 100644 index 0000000..da336db --- /dev/null +++ b/frontend/pweb/lib/models/currency.dart @@ -0,0 +1 @@ +enum Currency {usd, eur, rub, usdt, usdc} \ No newline at end of file diff --git a/frontend/pweb/lib/models/wallet.dart b/frontend/pweb/lib/models/wallet.dart new file mode 100644 index 0000000..ed376bb --- /dev/null +++ b/frontend/pweb/lib/models/wallet.dart @@ -0,0 +1,38 @@ +import 'package:pweb/models/currency.dart'; + + +class Wallet { + final String id; + final String walletUserID; // ID or number that we show the user + final String name; + final double balance; + final Currency currency; + final bool isHidden; + + Wallet({ + required this.id, + required this.walletUserID, + required this.name, + required this.balance, + required this.currency, + this.isHidden = true, + }); + + Wallet copyWith({ + String? id, + String? name, + double? balance, + Currency? currency, + String? walletUserID, + bool? isHidden, + }) { + return Wallet( + id: id ?? this.id, + name: name ?? this.name, + balance: balance ?? this.balance, + currency: currency ?? this.currency, + walletUserID: walletUserID ?? this.walletUserID, + isHidden: isHidden ?? this.isHidden, + ); + } +} diff --git a/frontend/pweb/lib/pages/2fa/error_message.dart b/frontend/pweb/lib/pages/2fa/error_message.dart new file mode 100644 index 0000000..9806182 --- /dev/null +++ b/frontend/pweb/lib/pages/2fa/error_message.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + + +class ErrorMessage extends StatelessWidget { + final String error; + + const ErrorMessage({super.key, required this.error}); + + @override + Widget build(BuildContext context) => Text( + error, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/2fa/input.dart b/frontend/pweb/lib/pages/2fa/input.dart new file mode 100644 index 0000000..ef8f69b --- /dev/null +++ b/frontend/pweb/lib/pages/2fa/input.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:pin_code_fields/pin_code_fields.dart'; + + +class TwoFactorCodeInput extends StatelessWidget { + final void Function(String) onCompleted; + + const TwoFactorCodeInput({super.key, required this.onCompleted}); + + @override + Widget build(BuildContext context) => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: PinCodeTextField( + length: 6, + appContext: context, + autoFocus: true, + keyboardType: TextInputType.number, + animationType: AnimationType.fade, + pinTheme: PinTheme( + shape: PinCodeFieldShape.box, + borderRadius: BorderRadius.circular(4), + fieldHeight: 48, + fieldWidth: 40, + inactiveColor: Theme.of(context).dividerColor, + activeColor: Theme.of(context).colorScheme.primary, + selectedColor: Theme.of(context).colorScheme.primary, + ), + onCompleted: onCompleted, + onChanged: (_) {}, + ), + ), + ); +} diff --git a/frontend/pweb/lib/pages/2fa/page.dart b/frontend/pweb/lib/pages/2fa/page.dart new file mode 100644 index 0000000..05c9b77 --- /dev/null +++ b/frontend/pweb/lib/pages/2fa/page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/2fa/error_message.dart'; +import 'package:pweb/pages/2fa/input.dart'; +import 'package:pweb/pages/2fa/prompt.dart'; +import 'package:pweb/pages/2fa/resend.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/providers/two_factor.dart'; + + + +class TwoFactorCodePage extends StatelessWidget { + final VoidCallback onVerificationSuccess; + + const TwoFactorCodePage({ + super.key, + required this.onVerificationSuccess, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + if (provider.verificationSuccess) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onVerificationSuccess(); + }); + } + + return Scaffold( + appBar: AppBar(title: const Text('')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const TwoFactorPromptText(), + const SizedBox(height: 32), + TwoFactorCodeInput( + onCompleted: (code) => provider.submitCode(code), + ), + const SizedBox(height: 24), + if (provider.isSubmitting) + const Center(child: CircularProgressIndicator()) + else + const ResendCodeButton(), + if (provider.hasError) ...[ + const SizedBox(height: 12), + ErrorMessage(error: AppLocalizations.of(context)!.twoFactorError), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/2fa/prompt.dart b/frontend/pweb/lib/pages/2fa/prompt.dart new file mode 100644 index 0000000..91f4923 --- /dev/null +++ b/frontend/pweb/lib/pages/2fa/prompt.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class TwoFactorPromptText extends StatelessWidget { + const TwoFactorPromptText({super.key}); + + @override + Widget build(BuildContext context) => Text( + AppLocalizations.of(context)!.twoFactorPrompt, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ); +} diff --git a/frontend/pweb/lib/pages/2fa/resend.dart b/frontend/pweb/lib/pages/2fa/resend.dart new file mode 100644 index 0000000..57bde0d --- /dev/null +++ b/frontend/pweb/lib/pages/2fa/resend.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ResendCodeButton extends StatelessWidget { + const ResendCodeButton({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final localizations = AppLocalizations.of(context)!; + + return TextButton( + onPressed: () { + // TODO: Add resend logic + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + alignment: Alignment.centerLeft, + foregroundColor: theme.colorScheme.primary, + textStyle: theme.textTheme.bodyMedium?.copyWith( + decoration: TextDecoration.underline, + ), + ), + child: Text(localizations.twoFactorResend), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/form/method_tile.dart b/frontend/pweb/lib/pages/address_book/form/method_tile.dart new file mode 100644 index 0000000..bb5ca51 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/method_tile.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/form.dart'; +import 'package:pweb/pages/payment_methods/icon.dart'; + + +class AdressBookPaymentMethodTile extends StatefulWidget { + final PaymentType type; + final String title; + final Map methods; + final ValueChanged onChanged; + + final double spacingM; + final double spacingS; + final double sizeM; + final TextStyle? titleTextStyle; + + const AdressBookPaymentMethodTile({ + super.key, + required this.type, + required this.title, + required this.methods, + required this.onChanged, + this.spacingM = 12, + this.spacingS = 8, + this.sizeM = 20, + this.titleTextStyle, + }); + + @override + State createState() => _AdressBookPaymentMethodTileState(); +} + +class _AdressBookPaymentMethodTileState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isAdded = widget.methods.containsKey(widget.type); + + return ExpansionTile( + title: Row( + children: [ + Icon( + iconForPaymentType(widget.type), + size: widget.sizeM, + color: isAdded + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + SizedBox(width: widget.spacingS), + Text( + widget.title, + style: widget.titleTextStyle ?? + theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isAdded ? theme.colorScheme.primary : null, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isAdded) + IconButton( + icon: Icon(Icons.delete, color: theme.colorScheme.error), + onPressed: () { + widget.onChanged(null); + }, + ), + Icon( + isAdded ? Icons.check_circle : Icons.add_circle_outline, + color: isAdded ? theme.colorScheme.primary : null, + ), + ], + ), + children: [ + PaymentMethodForm( + key: ValueKey(widget.type), + selectedType: widget.type, + initialData: widget.methods[widget.type], + onChanged: widget.onChanged, + ), + SizedBox(height: widget.spacingM), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart new file mode 100644 index 0000000..a970ca8 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/card.dart'; +import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/russian_bank.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; + +import 'package:pweb/pages/address_book/form/view.dart'; +import 'package:pweb/services/amplitude.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AdressBookRecipientForm extends StatefulWidget { + final Recipient? recipient; + final ValueChanged? onSaved; + + const AdressBookRecipientForm({super.key, this.recipient, this.onSaved}); + + @override + State createState() => _AdressBookRecipientFormState(); +} + +class _AdressBookRecipientFormState extends State { + final _formKey = GlobalKey(); + late TextEditingController _nameCtrl; + late TextEditingController _emailCtrl; + RecipientType _type = RecipientType.internal; + RecipientStatus _status = RecipientStatus.ready; + final Map _methods = {}; + + @override + void initState() { + super.initState(); + final r = widget.recipient; + _nameCtrl = TextEditingController(text: r?.name ?? ""); + _emailCtrl = TextEditingController(text: r?.email ?? ""); + _type = r?.type ?? RecipientType.internal; + _status = r?.status ?? RecipientStatus.ready; + + if (r?.card != null) _methods[PaymentType.card] = r!.card; + if (r?.iban != null) _methods[PaymentType.iban] = r!.iban; + if (r?.wallet != null) _methods[PaymentType.wallet] = r!.wallet; + if (r?.bank != null) _methods[PaymentType.bankAccount] = r!.bank; + } + + //TODO Change when registration is ready + void _save() { + if (!_formKey.currentState!.validate() || _methods.isEmpty) { + AmplitudeService.recipientAddCompleted( + _type, + _status, + _methods.keys.toSet(), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.recipientFormRule), + ), + ); + return; + } + + final recipient = Recipient( + name: _nameCtrl.text, + email: _emailCtrl.text, + type: _type, + status: _status, + avatarUrl: null, + card: _methods[PaymentType.card] as CardPaymentMethod?, + iban: _methods[PaymentType.iban] as IbanPaymentMethod?, + wallet: _methods[PaymentType.wallet] as WalletPaymentMethod?, + bank: _methods[PaymentType.bankAccount] as RussianBankAccountPaymentMethod?, + ); + + widget.onSaved?.call(recipient); + } + + @override + Widget build(BuildContext context) { + return FormView( + formKey: _formKey, + nameCtrl: _nameCtrl, + emailCtrl: _emailCtrl, + type: _type, + status: _status, + methods: _methods, + onTypeChanged: (t) => setState(() => _type = t), + onStatusChanged: (s) => setState(() => _status = s), + onMethodsChanged: (type, data) { + setState(() { + if (data != null) { + _methods[type] = data; + } else { + _methods.remove(type); + } + }); + }, + onSave: _save, + isEditing: widget.recipient != null, + onBack: () { + widget.onSaved?.call(null); + }, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/form/view.dart b/frontend/pweb/lib/pages/address_book/form/view.dart new file mode 100644 index 0000000..c49a8f5 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/view.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; + +import 'package:pweb/utils/payment/label.dart'; +import 'package:pweb/pages/address_book/form/method_tile.dart'; +import 'package:pweb/pages/address_book/form/widgets/button.dart'; +import 'package:pweb/pages/address_book/form/widgets/email_field.dart'; +import 'package:pweb/pages/address_book/form/widgets/header.dart'; +import 'package:pweb/pages/address_book/form/widgets/name_field.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class FormView extends StatelessWidget { + final GlobalKey formKey; + final TextEditingController nameCtrl; + final TextEditingController emailCtrl; + final RecipientType type; + final RecipientStatus status; + final Map methods; + final ValueChanged onTypeChanged; + final ValueChanged onStatusChanged; + final void Function(PaymentType, Object?) onMethodsChanged; + final VoidCallback onSave; + final bool isEditing; + final VoidCallback onBack; + + final double maxWidth; + final double elevation; + final double borderRadius; + final EdgeInsetsGeometry padding; + final double spacingHeader; + final double spacingFields; + final double spacingDivider; + final double spacingSave; + final double spacingBottom; + final TextStyle? titleTextStyle; + + const FormView({ + super.key, + required this.formKey, + required this.nameCtrl, + required this.emailCtrl, + required this.type, + required this.status, + required this.methods, + required this.onTypeChanged, + required this.onStatusChanged, + required this.onMethodsChanged, + required this.onSave, + required this.isEditing, + required this.onBack, + this.maxWidth = 500, + this.elevation = 4, + this.borderRadius = 16, + this.padding = const EdgeInsets.all(20), + this.spacingHeader = 20, + this.spacingFields = 12, + this.spacingDivider = 40, + this.spacingSave = 30, + this.spacingBottom = 16, + this.titleTextStyle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Material( + elevation: elevation, + borderRadius: BorderRadius.circular(borderRadius), + color: theme.colorScheme.onSecondary, + child: Padding( + padding: padding, + child: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeaderWidget( + isEditing: isEditing, + onBack: onBack, + ), + SizedBox(height: spacingHeader), + NameField(controller: nameCtrl), + SizedBox(height: spacingFields), + EmailField(controller: emailCtrl), + Divider(height: spacingDivider), + Text( + AppLocalizations.of(context)!.choosePaymentMethod, + style: titleTextStyle ?? + theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: spacingFields), + ...PaymentType.values.map( + (p) => AdressBookPaymentMethodTile( + type: p, + title: getPaymentTypeLabel(context, p), + methods: methods, + onChanged: (data) => onMethodsChanged(p, data), + ), + ), + SizedBox(height: spacingSave), + SaveButton(onSave: onSave), + SizedBox(height: spacingBottom), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/button.dart b/frontend/pweb/lib/pages/address_book/form/widgets/button.dart new file mode 100644 index 0000000..540d023 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SaveButton extends StatelessWidget { + final VoidCallback onSave; + + final double width; + final double height; + final double borderRadius; + final String? text; + final TextStyle? textStyle; + + const SaveButton({ + super.key, + required this.onSave, + this.width = 200, + this.height = 45, + this.borderRadius = 12, + this.text, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: SizedBox( + width: width, + height: height, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onSave, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Center( + child: Text( + text ?? AppLocalizations.of(context)!.saveRecipient, + style: textStyle ?? + theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart b/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart new file mode 100644 index 0000000..fe65c91 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/choice_chips.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + + +class ChoiceChips extends StatelessWidget { + final String label; + final List values; + final T selected; + final ValueChanged onChanged; + + final double spacing; + final double runSpacing; + final double labelSpacing; + + const ChoiceChips({ + super.key, + required this.label, + required this.values, + required this.selected, + required this.onChanged, + this.spacing = 8, + this.runSpacing = 8, + this.labelSpacing = 8, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: labelSpacing), + Wrap( + spacing: spacing, + runSpacing: runSpacing, + children: values.map((v) { + final isSelected = v == selected; + return ChoiceChip( + selectedColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.onSecondary, + showCheckmark: false, + label: Text( + v.toString().split('.').last, + style: TextStyle( + color: isSelected + ? theme.colorScheme.onSecondary + : theme.colorScheme.inverseSurface, + ), + ), + selected: isSelected, + onSelected: (_) => onChanged(v), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart b/frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart new file mode 100644 index 0000000..6def3e2 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/email_field.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class EmailField extends StatelessWidget { + final TextEditingController controller; + + final double borderRadius; + final EdgeInsetsGeometry contentPadding; + + const EmailField({ + super.key, + required this.controller, + this.borderRadius = 12, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: loc.username, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + ), + contentPadding: contentPadding, + ), + validator: (v) => + v == null || v.isEmpty ? loc.usernameErrorInvalid : null, + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/header.dart b/frontend/pweb/lib/pages/address_book/form/widgets/header.dart new file mode 100644 index 0000000..0585999 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/header.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class HeaderWidget extends StatelessWidget { + final bool isEditing; + final VoidCallback? onBack; + + final double spacing; + final TextStyle? textStyle; + + const HeaderWidget({ + super.key, + required this.isEditing, + this.onBack, + this.spacing = 8, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + color: theme.colorScheme.primary, + onPressed: onBack, + ), + SizedBox(width: spacing), + Text( + isEditing ? l10n.editRecipient : l10n.addRecipient, + style: textStyle ?? + theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart b/frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart new file mode 100644 index 0000000..9c1f5f5 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/form/widgets/name_field.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class NameField extends StatelessWidget { + final TextEditingController controller; + + final double borderRadius; + final EdgeInsetsGeometry contentPadding; + + const NameField({ + super.key, + required this.controller, + this.borderRadius = 12, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: loc.recipientName, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(borderRadius), + ), + contentPadding: contentPadding, + ), + validator: (v) => v == null || v.isEmpty ? loc.enterRecipientName : null, + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/filter_button.dart b/frontend/pweb/lib/pages/address_book/page/filter_button.dart new file mode 100644 index 0000000..b4f1a80 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/filter_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/filter.dart'; + + +class RecipientFilterButton extends StatelessWidget { + final String text; + final RecipientFilter filter; + final RecipientFilter selected; + final ValueChanged onTap; + + const RecipientFilterButton({ + super.key, + required this.text, + required this.filter, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isSelected = selected == filter; + final theme = Theme.of(context).colorScheme; + + return ElevatedButton( + onPressed: () => onTap(filter), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(Colors.transparent), + overlayColor: WidgetStateProperty.all(Colors.transparent), + shadowColor: WidgetStateProperty.all(Colors.transparent), + elevation: WidgetStateProperty.all(0), + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + text, + style: TextStyle( + fontSize: 20, + color: isSelected + ? theme.onPrimaryContainer + : theme.onPrimaryContainer.withAlpha(60), + ), + ), + SizedBox( + height: 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? theme.primary + : theme.onPrimaryContainer.withAlpha(60), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/header.dart b/frontend/pweb/lib/pages/address_book/page/header.dart new file mode 100644 index 0000000..84d033b --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/header.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RecipientAddressBookHeader extends StatelessWidget { + final VoidCallback onAddRecipient; + + const RecipientAddressBookHeader({super.key, required this.onAddRecipient}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + final l10 = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10.recipients, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + TextButton.icon( + onPressed: onAddRecipient, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(theme.primary), + shadowColor: WidgetStateProperty.all(theme.onPrimaryContainer), + elevation: WidgetStateProperty.all(2), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + icon: Icon(Icons.add, color: theme.onSecondary), + label: Text(l10.addRecipient, style: TextStyle(color: theme.onSecondary)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/page/list.dart b/frontend/pweb/lib/pages/address_book/page/list.dart new file mode 100644 index 0000000..1555dc3 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/list.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/address_book/page/recipient/item.dart'; + + +class RecipientAddressBookList extends StatelessWidget { + final List filteredRecipients; + final ValueChanged? onSelected; + final ValueChanged? onEdit; + final ValueChanged? onDelete; + + const RecipientAddressBookList({ + super.key, + required this.filteredRecipients, + this.onSelected, + this.onEdit, + this.onDelete, + }); + + @override +Widget build(BuildContext context) { + return ListView.builder( + itemCount: filteredRecipients.length, + itemBuilder: (context, index) { + final recipient = filteredRecipients[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: RecipientAddressBookItem( + recipient: recipient, + onTap: () => onSelected?.call(recipient), + onEdit: () => onEdit?.call(recipient), + onDelete: () => onDelete?.call(recipient), + ), + ); + }, + ); +} +} diff --git a/frontend/pweb/lib/pages/address_book/page/page.dart b/frontend/pweb/lib/pages/address_book/page/page.dart new file mode 100644 index 0000000..e459bba --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pshared/models/recipient/filter.dart'; +import 'package:pweb/pages/address_book/page/filter_button.dart'; +import 'package:pweb/pages/address_book/page/header.dart'; +import 'package:pweb/pages/address_book/page/list.dart'; +import 'package:pweb/pages/address_book/page/search.dart'; +import 'package:pweb/providers/recipient.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RecipientAddressBookPage extends StatelessWidget { + final ValueChanged onRecipientSelected; + final VoidCallback onAddRecipient; + final ValueChanged? onEditRecipient; + + const RecipientAddressBookPage({ + super.key, + required this.onRecipientSelected, + required this.onAddRecipient, + this.onEditRecipient, + }); + + static const double _expandedHeight = 550; + static const double _paddingAll = 16; + static const double _bigBox = 30; + static const double _smallBox = 20; + + + @override + Widget build(BuildContext context) { + + final loc = AppLocalizations.of(context)!; + final provider = context.watch(); + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); //TODO This should be in the provider + } + + if (provider.error != null) { + return Center(child: Text('Error: ${provider.error}')); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RecipientAddressBookHeader(onAddRecipient: onAddRecipient), + const SizedBox(height: _smallBox), + RecipientSearchField( + controller: TextEditingController(text: provider.query), + focusNode: FocusNode(), + onChanged: provider.setQuery, + ), + const SizedBox(height: _bigBox), + Row( + children: [ + RecipientFilterButton( + text: loc.allStatus, + filter: RecipientFilter.all, + selected: provider.selectedFilter, + onTap: provider.setFilter, + ), + RecipientFilterButton( + text: loc.readyStatus, + filter: RecipientFilter.ready, + selected: provider.selectedFilter, + onTap: provider.setFilter, + ), + RecipientFilterButton( + text: loc.registeredStatus, + filter: RecipientFilter.registered, + selected: provider.selectedFilter, + onTap: provider.setFilter, + ), + RecipientFilterButton( + text: loc.notRegisteredStatus, + filter: RecipientFilter.notRegistered, + selected: provider.selectedFilter, + onTap: provider.setFilter, + ), + ], + ), + SizedBox( + height: _expandedHeight, + child: Padding( + padding: const EdgeInsets.all(_paddingAll), + child: RecipientAddressBookList( + filteredRecipients: provider.filteredRecipients, + onEdit: (recipient) => onEditRecipient?.call(recipient), + onSelected: onRecipientSelected, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/actions.dart b/frontend/pweb/lib/pages/address_book/page/recipient/actions.dart new file mode 100644 index 0000000..8007a83 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/actions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + + +class RecipientActions extends StatelessWidget { + final VoidCallback onEdit; + final VoidCallback onDelete; + + const RecipientActions({super.key, required this.onEdit, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconButton(icon: Icon(Icons.edit, color: Theme.of(context).colorScheme.primary), onPressed: onEdit), + IconButton(icon: Icon(Icons.delete, color: Theme.of(context).colorScheme.error), onPressed: onDelete), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/info_column.dart b/frontend/pweb/lib/pages/address_book/page/recipient/info_column.dart new file mode 100644 index 0000000..64e941a --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/info_column.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + + +class RecipientInfoColumn extends StatelessWidget { + final String name; + final String email; + + const RecipientInfoColumn({super.key, required this.name, required this.email}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 19)), + Text(email, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart b/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart new file mode 100644 index 0000000..869088e --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/info_row.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/icon.dart'; +import 'package:pweb/utils/payment/label.dart'; + + +class RecipientAddressBookInfoRow extends StatelessWidget { + final PaymentType type; + final String value; + + final double spacingWidth; + final double spacingHeight; + final double iconSize; + final double titleFontSize; + final double valueFontSize; + final TextStyle? textStyle; + + const RecipientAddressBookInfoRow({ + super.key, + required this.type, + required this.value, + this.spacingWidth = 8.0, + this.spacingHeight = 2.0, + this.iconSize = 20.0, + this.titleFontSize = 16.0, + this.valueFontSize = 12.0, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = textStyle ?? Theme.of(context).textTheme.bodySmall!; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(iconForPaymentType(type), size: iconSize), + SizedBox(width: spacingWidth), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + getPaymentTypeLabel(context, type), + style: style.copyWith(fontSize: titleFontSize), + ), + SizedBox(height: spacingHeight), + Text( + value, + style: style.copyWith(fontSize: valueFontSize), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/item.dart b/frontend/pweb/lib/pages/address_book/page/recipient/item.dart new file mode 100644 index 0000000..f8b3917 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/item.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/address_book/page/recipient/actions.dart'; +import 'package:pweb/pages/address_book/page/recipient/info_column.dart'; +import 'package:pweb/pages/address_book/page/recipient/payment_row.dart'; +import 'package:pweb/pages/address_book/page/recipient/status.dart'; +import 'package:pweb/pages/dashboard/payouts/single/adress_book/avatar.dart'; + + +class RecipientAddressBookItem extends StatefulWidget { + final Recipient recipient; + final VoidCallback onTap; + final VoidCallback onEdit; + final VoidCallback onDelete; + + final double borderRadius; + final double elevation; + final EdgeInsetsGeometry padding; + final double spacingDotAvatar; + final double spacingAvatarInfo; + final double spacingBottom; + final double avatarRadius; + + const RecipientAddressBookItem({ + super.key, + required this.recipient, + required this.onTap, + required this.onEdit, + required this.onDelete, + this.borderRadius = 12, + this.elevation = 4, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + this.spacingDotAvatar = 8, + this.spacingAvatarInfo = 16, + this.spacingBottom = 10, + this.avatarRadius = 24, + }); + + @override + State createState() => _RecipientAddressBookItemState(); +} + +class _RecipientAddressBookItemState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final recipient = widget.recipient; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: InkWell( + onTap: widget.onTap, + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius)), + elevation: widget.elevation, + color: Theme.of(context).colorScheme.onSecondary, + child: Padding( + padding: widget.padding, + child: Column( + children: [ + Row( + children: [ + RecipientStatusDot(status: recipient.status), + SizedBox(width: widget.spacingDotAvatar), + RecipientAvatar( + name: recipient.name, + avatarUrl: recipient.avatarUrl, + isVisible: false, + avatarRadius: widget.avatarRadius, + ), + SizedBox(width: widget.spacingAvatarInfo), + Expanded( + child: RecipientInfoColumn( + name: recipient.name, + email: recipient.email, + ), + ), + if (_isHovered) + RecipientActions( + onEdit: widget.onEdit, onDelete: widget.onDelete), + ], + ), + SizedBox(height: widget.spacingBottom), + RecipientPaymentRow(recipient: recipient), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart new file mode 100644 index 0000000..1cd3aaf --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/address_book/page/recipient/info_row.dart'; + + +class RecipientPaymentRow extends StatelessWidget { + final Recipient recipient; + final double spacing; + + const RecipientPaymentRow({ + super.key, + required this.recipient, + this.spacing = 18 + }); + + @override + Widget build(BuildContext context) { + return Row( + spacing: spacing, + children: [ + if (recipient.bank?.accountNumber.isNotEmpty ?? false) + RecipientAddressBookInfoRow( + type: PaymentType.bankAccount, + value: recipient.bank!.accountNumber + ), + if (recipient.card?.pan.isNotEmpty ?? false) + RecipientAddressBookInfoRow( + type: PaymentType.card, + value: recipient.card!.pan + ), + if (recipient.iban?.iban.isNotEmpty ?? false) + RecipientAddressBookInfoRow( + type: PaymentType.iban, + value: recipient.iban!.iban + ), + if (recipient.wallet?.walletId.isNotEmpty ?? false) + RecipientAddressBookInfoRow( + type: PaymentType.wallet, + value: recipient.wallet!.walletId + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/status.dart b/frontend/pweb/lib/pages/address_book/page/recipient/status.dart new file mode 100644 index 0000000..a9a7db3 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/recipient/status.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/status.dart'; + + +class RecipientStatusDot extends StatelessWidget { + final RecipientStatus status; + + const RecipientStatusDot({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + Color color; + switch (status) { + case RecipientStatus.ready: + color = Colors.green; + break; + case RecipientStatus.notRegistered: + color = Theme.of(context).colorScheme.error; + break; + case RecipientStatus.registered: + color = Colors.yellow; + break; + } + + return Container( + width: 12, + height: 12, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ); + } +} diff --git a/frontend/pweb/lib/pages/address_book/page/search.dart b/frontend/pweb/lib/pages/address_book/page/search.dart new file mode 100644 index 0000000..5d00c25 --- /dev/null +++ b/frontend/pweb/lib/pages/address_book/page/search.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RecipientSearchField extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final FocusNode? focusNode; + + const RecipientSearchField({ + super.key, + required this.controller, + required this.onChanged, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: l10n.searchHint, + border: const OutlineInputBorder(), + fillColor: Theme.of(context).colorScheme.onSecondary, + filled: true, + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + controller.clear(); + onChanged(''); + focusNode?.unfocus(); + }, + ), + ), + onChanged: onChanged, + ); + + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add_funds.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add_funds.dart new file mode 100644 index 0000000..49f4658 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add_funds.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + + +class BalanceAddFunds extends StatelessWidget { + final VoidCallback onTopUp; + + const BalanceAddFunds({ + super.key, + required this.onTopUp, + }); + + static const double _borderRadius = 5.0; + static const double _iconSize = 24.0; + static const double _paddingVertical = 2.0; + static const double _spacingSmall = 3.0; + static const double _spacingMedium = 5.0; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: onTopUp, + borderRadius: BorderRadius.circular(_borderRadius), + hoverColor: colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: _paddingVertical), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _spacingSmall), + Icon( + Icons.add_circle, + color: colorScheme.primary, + size: _iconSize, + ), + const SizedBox(width: _spacingMedium), + Text( + 'Add funds', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: _spacingSmall), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/amount.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/amount.dart new file mode 100644 index 0000000..409a59b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/amount.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/utils/currency.dart'; + + +class BalanceAmount extends StatelessWidget { + final Wallet wallet; + final VoidCallback onToggleVisibility; + + const BalanceAmount({ + super.key, + required this.wallet, + required this.onToggleVisibility, + }); + + static const double _iconSpacing = 12.0; + static const double _iconSize = 24.0; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final currencyBalance = currencyCodeToSymbol(wallet.currency); + + return Row( + children: [ + Text( + wallet.isHidden ? '•••• $currencyBalance' : '${wallet.balance.toStringAsFixed(2)} $currencyBalance', + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(width: _iconSpacing), + GestureDetector( + onTap: onToggleVisibility, + child: Icon( + wallet.isHidden ? Icons.visibility_off : Icons.visibility, + size: _iconSize, + color: colorScheme.onSurface, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart new file mode 100644 index 0000000..6f2fb87 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart'; +import 'package:pweb/providers/wallets.dart'; + + +class BalanceWidget extends StatelessWidget { + const BalanceWidget({super.key}); + + @override + Widget build(BuildContext context) { + final walletsProvider = context.watch(); + + if (walletsProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final wallets = walletsProvider.wallets; + + if (wallets == null || wallets.isEmpty) { + return const Center(child: Text('No wallets available')); + } + + return + WalletCarousel( + wallets: wallets, + onWalletChanged: walletsProvider.selectWallet, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart new file mode 100644 index 0000000..3586bd8 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; +import 'package:pweb/providers/wallets.dart'; + + +class WalletCard extends StatelessWidget { + final Wallet wallet; + + const WalletCard({ + super.key, + required this.wallet, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).colorScheme.onSecondary, + elevation: WalletCardConfig.elevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius), + ), + child: Padding( + padding: WalletCardConfig.contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BalanceHeader( + walletName: wallet.name, + walletId: wallet.walletUserID, + ), + BalanceAmount( + wallet: wallet, + onToggleVisibility: () { + context.read().toggleVisibility(wallet.id); + }, + ), + BalanceAddFunds( + onTopUp: () { + // TODO: Implement top-up functionality + }, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart new file mode 100644 index 0000000..426cd7b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/carousel.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/card.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart'; +import 'package:pweb/providers/carousel.dart'; + + +class WalletCarousel extends StatefulWidget { + final List wallets; + final ValueChanged onWalletChanged; + + const WalletCarousel({ + super.key, + required this.wallets, + required this.onWalletChanged, + }); + + @override + State createState() => _WalletCarouselState(); +} + +class _WalletCarouselState extends State { + late final PageController _pageController; + int _currentPage = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController( + viewportFraction: WalletCardConfig.viewportFraction, + ); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int index) { + setState(() { + _currentPage = index; + }); + context.read().updateIndex(index); + widget.onWalletChanged(widget.wallets[index]); + } + + void _goToPreviousPage() { + if (_currentPage > 0) { + _pageController.animateToPage( + _currentPage - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + void _goToNextPage() { + if (_currentPage < widget.wallets.length - 1) { + _pageController.animateToPage( + _currentPage + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: WalletCardConfig.cardHeight, + child: PageView.builder( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.wallets.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + return Padding( + padding: WalletCardConfig.cardPadding, + child: WalletCard(wallet: widget.wallets[index]), + ); + }, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: _currentPage > 0 ? _goToPreviousPage : null, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 16), + CarouselIndicator(itemCount: widget.wallets.length), + const SizedBox(width: 16), + IconButton( + onPressed: _currentPage < widget.wallets.length - 1 + ? _goToNextPage + : null, + icon: const Icon(Icons.arrow_forward), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/config.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/config.dart new file mode 100644 index 0000000..135895d --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/config.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + + +abstract class WalletCardConfig { + static const double cardHeight = 130.0; + static const double elevation = 4.0; + static const double borderRadius = 16.0; + static const double viewportFraction = 0.9; + + static const EdgeInsets cardPadding = EdgeInsets.symmetric(horizontal: 8); + static const EdgeInsets contentPadding = EdgeInsets.all(16); + + static const double dotSize = 8.0; + static const EdgeInsets dotMargin = EdgeInsets.symmetric(horizontal: 4); +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart new file mode 100644 index 0000000..48c5090 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/header.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + + +class BalanceHeader extends StatelessWidget { + final String walletName; + final String walletId; + + const BalanceHeader({ + super.key, + required this.walletName, + required this.walletId, + }); + + static const double _spacing = 8.0; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Row( + children: [ + Text( + walletName, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(width: _spacing), + Text( + walletId, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/indicator.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/indicator.dart new file mode 100644 index 0000000..d28c8c2 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/indicator.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; + +import 'package:pweb/providers/carousel.dart'; + + +class CarouselIndicator extends StatelessWidget { + final int itemCount; + + const CarouselIndicator({ + super.key, + required this.itemCount, + }); + + @override + Widget build(BuildContext context) { + final currentIndex = context.watch().currentIndex; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + itemCount, + (index) => _Dot(isActive: currentIndex == index), + ), + ); + } +} + +class _Dot extends StatelessWidget { + final bool isActive; + + const _Dot({required this.isActive}); + + @override + Widget build(BuildContext context) { + return Container( + width: WalletCardConfig.dotSize, + height: WalletCardConfig.dotSize, + margin: WalletCardConfig.dotMargin, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withAlpha(60), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/buttons.dart b/frontend/pweb/lib/pages/dashboard/buttons/buttons.dart new file mode 100644 index 0000000..a32b9d6 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/buttons.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + + +class TransactionRefButton extends StatelessWidget { + final VoidCallback onTap; + final bool isActive; + final String label; + final IconData icon; + + const TransactionRefButton({ + super.key, + required this.onTap, + required this.isActive, + required this.label, + required this.icon, + }); + + static const double _horizontalPadding = 10.0; + static const double _verticalPadding = 5.0; + static const double _iconSize = 24.0; + static const double _spacing = 10.0; + static const double _borderRadius = 12.0; + static const FontWeight _fontWeight = FontWeight.w400; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + + final backgroundColor = isActive ? theme.primary : theme.onSecondary; + final foregroundColor = isActive ? theme.onPrimary : theme.onPrimaryContainer; + final hoverColor = isActive ? theme.primary : theme.secondaryContainer; + + return Material( + color: backgroundColor, + elevation: 4, + borderRadius: BorderRadius.circular(_borderRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(_borderRadius), + hoverColor: hoverColor, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _horizontalPadding, + vertical: _verticalPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: _fontWeight, + color: foregroundColor, + ), + ), + const SizedBox(width: _spacing), + Icon( + icon, + color: foregroundColor, + size: _iconSize, + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart new file mode 100644 index 0000000..55d8690 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/balance.dart'; +import 'package:pweb/pages/dashboard/buttons/buttons.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/title.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/single/widget.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AppSpacing { + static const double small = 10; + static const double medium = 16; + static const double large = 20; +} + +class DashboardPage extends StatefulWidget { + final ValueChanged onRecipientSelected; + final void Function(PaymentType type) onGoToPaymentWithoutRecipient; + + const DashboardPage({ + super.key, + required this.onRecipientSelected, + required this.onGoToPaymentWithoutRecipient, + }); + + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + bool _showContainerSingle = true; + bool _showContainerMultiple = false; + + void _setActive(bool single) { + setState(() { + _showContainerSingle = single; + _showContainerMultiple = !single; + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(true), + isActive: _showContainerSingle, + label: AppLocalizations.of(context)!.sendSingle, + icon: Icons.person_add, + ), + ), + const SizedBox(width: AppSpacing.small), + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(false), + isActive: _showContainerMultiple, + label: AppLocalizations.of(context)!.sendMultiple, + icon: Icons.group_add, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.medium), + BalanceWidget(), + const SizedBox(height: AppSpacing.small), + if (_showContainerMultiple) TitleMultiplePayout(), + const SizedBox(height: AppSpacing.medium), + if (_showContainerSingle) + SinglePayoutForm( + onRecipientSelected: widget.onRecipientSelected, + onGoToPayment: widget.onGoToPaymentWithoutRecipient, + ), + if (_showContainerMultiple) MultiplePayoutForm(), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/organization/button.dart b/frontend/pweb/lib/pages/dashboard/organization/button.dart new file mode 100644 index 0000000..62b4115 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/organization/button.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + + +class OrganizationButton extends StatelessWidget { + const OrganizationButton({super.key}); + + @override + Widget build(BuildContext context) => IconButton( + icon: Icon(Icons.person), + onPressed: null, + ); +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart new file mode 100644 index 0000000..8a169a4 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UploadCSVSection extends StatelessWidget { + const UploadCSVSection({super.key}); + + static const double _verticalSpacing = 10; + static const double _iconTextSpacing = 5; + static const double _buttonVerticalPadding = 12; + static const double _buttonHorizontalPadding = 24; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.upload), + const SizedBox(width: _iconTextSpacing), + Text( + l10n.uploadCSV, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: _verticalSpacing), + Container( + height: 140, + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: _buttonHorizontalPadding, + vertical: _buttonVerticalPadding, + ), + ), + child: Text(l10n.upload), + ), + const SizedBox(height: 8), + Text( + l10n.hintUpload, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart new file mode 100644 index 0000000..333a09a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart @@ -0,0 +1,13 @@ +class MultiplePayoutRow { + final String token; + final String amount; + final String currency; + final String comment; + + const MultiplePayoutRow({ + required this.token, + required this.amount, + required this.currency, + required this.comment, + }); +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart new file mode 100644 index 0000000..0760430 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + +import 'package:pweb/providers/upload_history.dart'; + + +class UploadHistorySection extends StatelessWidget { + const UploadHistorySection({super.key}); + + static const double _smallBox = 5; + static const double _radius = 6; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final theme = Theme.of(context); + final l10 = AppLocalizations.of(context)!; + + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (provider.error != null) { + return Text("Error: ${provider.error}"); + } + final items = provider.data ?? []; + + return Column( + children: [ + Row( + children: [ + const Icon(Icons.history), + const SizedBox(width: _smallBox), + Text(l10.uploadHistory, style: theme.textTheme.bodyLarge), + ], + ), + DataTable( + columns: [ + DataColumn(label: Text(l10.fileNameColumn)), + DataColumn(label: Text(l10.colStatus)), + DataColumn(label: Text(l10.dateColumn)), + DataColumn(label: Text(l10.details)), + ], + rows: items.map((file) { + final isError = file.status == "Error"; + final statusColor = isError ? Colors.red : Colors.green; + return DataRow( + cells: [ + DataCell(Text(file.name)), + DataCell(Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withAlpha(20), + borderRadius: BorderRadius.circular(_radius), + ), + child: Text(file.status, style: TextStyle(color: statusColor)), + )), + DataCell(Text(file.time)), + DataCell(TextButton(onPressed: () {}, child: Text(l10.showDetails))), + ], + ); + }).toList(), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart new file mode 100644 index 0000000..ce39fce --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/form.dart'; + + +class FileFormatSampleSection extends StatelessWidget { + const FileFormatSampleSection({super.key}); + + static final List sampleRows = [ + MultiplePayoutRow(token: "d921...161", amount: "500", currency: "RUB", comment: "cashback001"), + MultiplePayoutRow(token: "d921...162", amount: "100", currency: "USD", comment: "cashback002"), + MultiplePayoutRow(token: "d921...163", amount: "120", currency: "EUR", comment: "cashback003"), + ]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + final titleStyle = theme.textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w600, + ); + + final linkStyle = theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.filter_list), + const SizedBox(width: 5), + Text(l10n.exampleTitle, style: titleStyle), + ], + ), + const SizedBox(height: 12), + _buildDataTable(l10n), + const SizedBox(height: 10), + TextButton( + onPressed: () {}, + style: TextButton.styleFrom(padding: EdgeInsets.zero), + child: Text(l10n.downloadSampleCSV, style: linkStyle), + ), + ], + ); + } + + Widget _buildDataTable(AppLocalizations l10n) { + return DataTable( + columnSpacing: 20, + columns: [ + DataColumn(label: Text(l10n.tokenColumn)), + DataColumn(label: Text(l10n.amount)), + DataColumn(label: Text(l10n.currency)), + DataColumn(label: Text(l10n.comment)), + ], + rows: sampleRows.map((row) { + return DataRow(cells: [ + DataCell(Text(row.token)), + DataCell(Text(row.amount)), + DataCell(Text(row.currency)), + DataCell(Text(row.comment)), + ]); + }).toList(), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/title.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/title.dart new file mode 100644 index 0000000..aee119c --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/title.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class TitleMultiplePayout extends StatelessWidget { + const TitleMultiplePayout({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.multiplePayout, + style: theme.textTheme.titleLarge, + ), + const SizedBox(width: 20), + Text( + AppLocalizations.of(context)!.howItWorks, + style: theme.textTheme.bodyLarge!.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart new file mode 100644 index 0000000..cf3efe6 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/widget.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/dashboard/payouts/multiple/csv.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/history.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/sample.dart'; + + +class MultiplePayoutForm extends StatelessWidget { + const MultiplePayoutForm({super.key}); + + static const double _spacing = 12; + static const double _bottomSpacing = 40; + + static final List _cards = const [ + FileFormatSampleSection(), + UploadCSVSection(), + UploadHistorySection(), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (int i = 0; i < _cards.length; i++) ...[ + _StyledCard(child: _cards[i]), + if (i < _cards.length - 1) const SizedBox(height: _spacing), + ], + const SizedBox(height: _bottomSpacing), + ], + ); + } +} + +class _StyledCard extends StatelessWidget { + final Widget child; + const _StyledCard({required this.child}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: double.infinity, + child: Card( + margin: const EdgeInsets.all(1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 4, + color: theme.colorScheme.onSecondary, + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/payment_form.dart b/frontend/pweb/lib/pages/dashboard/payouts/payment_form.dart new file mode 100644 index 0000000..4285416 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/payment_form.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/providers/mock_payment.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentFormWidget extends StatelessWidget { + const PaymentFormWidget({super.key}); + + static const double _smallSpacing = 5; + static const double _mediumSpacing = 10; + static const double _largeSpacing = 16; + static const double _extraSpacing = 15; + + String _formatAmount(double amount) => amount.toStringAsFixed(2); + + @override + Widget build(BuildContext context) { + final provider = Provider.of(context); + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(loc.details, style: theme.textTheme.titleMedium), + const SizedBox(height: _smallSpacing), + + TextField( + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: loc.amount, + border: const OutlineInputBorder(), + ), + onChanged: (val) { + final parsed = double.tryParse(val.replaceAll(',', '.')) ?? 0.0; + provider.setAmount(parsed); + }, + ), + + const SizedBox(height: _mediumSpacing), + + Row( + spacing: _mediumSpacing, + children: [ + Text(loc.recipientPaysFee, style: theme.textTheme.titleMedium), + Switch( + value: !provider.payerCoversFee, + onChanged: (val) => provider.setPayerCoversFee(!val), + ), + ], + ), + + const SizedBox(height: _largeSpacing), + + Align( + alignment: Alignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryRow(label: loc.sentAmount(_formatAmount(provider.amount)), style: theme.textTheme.titleMedium), + _SummaryRow(label: loc.fee(_formatAmount(provider.fee)), style: theme.textTheme.titleMedium), + _SummaryRow(label: loc.recipientWillReceive(_formatAmount(provider.recipientGets)), style: theme.textTheme.titleMedium), + + const SizedBox(height: _extraSpacing), + + _SummaryRow( + label: loc.total(_formatAmount(provider.total)), + style: theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ); + } +} + +class _SummaryRow extends StatelessWidget { + final String label; + final TextStyle? style; + + const _SummaryRow({required this.label, this.style}); + + @override + Widget build(BuildContext context) { + return Text(label, style: style); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/avatar.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/avatar.dart new file mode 100644 index 0000000..cf4f74b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/avatar.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/initials.dart'; + + +class RecipientAvatar extends StatelessWidget { + final String name; + final String? avatarUrl; + final double avatarRadius; + final TextStyle? nameStyle; + final bool isVisible; + + static const double _verticalSpacing = 5; + + const RecipientAvatar({ + super.key, + required this.name, + this.avatarUrl, + required this.avatarRadius, + this.nameStyle, + required this.isVisible, + }); + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).colorScheme.onPrimary; + + return Column( + children: [ + CircleAvatar( + radius: avatarRadius, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, + backgroundColor: Theme.of(context).colorScheme.primary, + child: avatarUrl == null + ? Text( + getInitials(name), + style: TextStyle( + color: textColor, + fontSize: avatarRadius * 0.8, + ), + ) + : null, + ), + const SizedBox(height: _verticalSpacing), + if (isVisible) + Text( + name, + overflow: TextOverflow.ellipsis, + style: nameStyle ?? Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/info_row.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/info_row.dart new file mode 100644 index 0000000..bdcf814 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/info_row.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class PaymentInfoRow extends StatelessWidget { + final String label; + final String value; + + const PaymentInfoRow({ + super.key, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: 8), + Text(value, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart new file mode 100644 index 0000000..76c9c74 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/payouts/single/adress_book/avatar.dart'; +import 'package:pweb/pages/dashboard/payouts/single/adress_book/long_list/info_row.dart'; +import 'package:pweb/utils/payment/label.dart'; + + +class RecipientItem extends StatelessWidget { + final Recipient recipient; + final VoidCallback onTap; + + static const double _horizontalPadding = 16.0; + static const double _verticalPadding = 8.0; + static const double _avatarRadius = 20; + static const double _spacingWidth = 12; + + const RecipientItem({ + super.key, + required this.recipient, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _horizontalPadding, + vertical: _verticalPadding, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: RecipientAvatar( + isVisible: false, + name: recipient.name, + avatarUrl: recipient.avatarUrl, + avatarRadius: _avatarRadius, + nameStyle: Theme.of(context).textTheme.bodyMedium, + ), + title: Text(recipient.name), + subtitle: Text(recipient.email), + ), + ), + const SizedBox(width: _spacingWidth), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (recipient.bank?.accountNumber.isNotEmpty == true) + PaymentInfoRow( + label: getPaymentTypeLabel(context, PaymentType.bankAccount), + value: recipient.bank!.accountNumber, + ), + if (recipient.card?.pan.isNotEmpty == true) + PaymentInfoRow( + label: getPaymentTypeLabel(context, PaymentType.card), + value: recipient.card!.pan, + ), + if (recipient.iban?.iban.isNotEmpty == true) + PaymentInfoRow( + label: getPaymentTypeLabel(context, PaymentType.iban), + value: recipient.iban!.iban, + ), + if (recipient.wallet?.walletId.isNotEmpty == true) + PaymentInfoRow( + label: getPaymentTypeLabel(context, PaymentType.wallet), + value: recipient.wallet!.walletId, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/long_list.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/long_list.dart new file mode 100644 index 0000000..5f9f664 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/long_list.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/payouts/single/adress_book/long_list/item.dart'; + + +class LongListAdressBookPayout extends StatelessWidget { + final List filteredRecipients; + final ValueChanged? onSelected; + + const LongListAdressBookPayout({ + super.key, + required this.filteredRecipients, + this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: filteredRecipients.length, + itemBuilder: (context, index) { + final recipient = filteredRecipients[index]; + return RecipientItem( + recipient: recipient, + onTap: () => onSelected!(recipient), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/short_list.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/short_list.dart new file mode 100644 index 0000000..8486f1c --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/short_list.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pweb/pages/dashboard/payouts/single/adress_book/avatar.dart'; + + +class ShortListAdressBookPayout extends StatelessWidget { + final List recipients; + final ValueChanged onSelected; + + const ShortListAdressBookPayout({ + super.key, + required this.recipients, + required this.onSelected, + }); + + static const double _avatarRadius = 20; + static const double _avatarSize = 80; + static const EdgeInsets _padding = EdgeInsets.symmetric(horizontal: 10, vertical: 8); + static const TextStyle _nameStyle = TextStyle(fontSize: 12); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: recipients.map((recipient) { + return Padding( + padding: _padding, + child: InkWell( + borderRadius: BorderRadius.circular(5), + hoverColor: Theme.of(context).colorScheme.primaryContainer, + onTap: () => onSelected(recipient), + child: SizedBox( + height: _avatarSize, + width: _avatarSize, + child: RecipientAvatar( + isVisible: true, + name: recipient.name, + avatarUrl: recipient.avatarUrl, + avatarRadius: _avatarRadius, + nameStyle: _nameStyle, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/widget.dart new file mode 100644 index 0000000..4974e05 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/widget.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/address_book/page/search.dart'; +import 'package:pweb/pages/dashboard/payouts/single/adress_book/long_list/long_list.dart'; +import 'package:pweb/pages/dashboard/payouts/single/adress_book/short_list.dart'; +import 'package:pweb/providers/recipient.dart'; + + +class AdressBookPayout extends StatefulWidget { + final ValueChanged onSelected; + + const AdressBookPayout({ + super.key, + required this.onSelected, + }); + + @override + State createState() => _AdressBookPayoutState(); +} + +class _AdressBookPayoutState extends State { + static const double _expandedHeight = 400; + static const double _collapsedHeight = 200; + static const double _cardMargin = 1; + static const double _paddingAll = 16; + static const double _spacingBetween = 16; + + final FocusNode _searchFocusNode = FocusNode(); + late final TextEditingController _searchController; + + bool get _isExpanded => _searchFocusNode.hasFocus; + + @override + void initState() { + super.initState(); + final provider = context.read(); + _searchController = TextEditingController(text: provider.query); + + _searchController.addListener(() { + provider.setQuery(_searchController.text); + }); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.error != null) { + return Center(child: Text('Error: ${provider.error}')); + } + + return SizedBox( + height: _isExpanded ? _expandedHeight : _collapsedHeight, + child: Card( + margin: const EdgeInsets.all(_cardMargin), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 4, + color: Theme.of(context).colorScheme.onSecondary, + child: Padding( + padding: const EdgeInsets.all(_paddingAll), + child: Column( + children: [ + RecipientSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) {}, + ), + const SizedBox(height: _spacingBetween), + Expanded( + child: _isExpanded + ? LongListAdressBookPayout( + filteredRecipients: provider.filteredRecipients, + onSelected: widget.onSelected, + ) + : ShortListAdressBookPayout( + recipients: provider.recipients, + onSelected: widget.onSelected, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart new file mode 100644 index 0000000..52cd185 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/form.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentDetailsSection extends StatelessWidget { + final bool isFormVisible; + final bool isEditable; + final VoidCallback? onToggle; + final PaymentType? selectedType; + final Object? data; + + const PaymentDetailsSection({ + super.key, + required this.isFormVisible, + this.onToggle, + required this.selectedType, + required this.data, + required this.isEditable, + }); + + static const double toggleSpacing = 8.0; + static const double formVisibleSpacing = 30.0; + static const double formHiddenSpacing = 20.0; + static const Duration animationDuration = Duration(milliseconds: 200); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + final toggleIcon = isFormVisible ? Icons.expand_less : Icons.expand_more; + final toggleText = isFormVisible ? loc.hideDetails : loc.showDetails; + + return Column( + children: [ + if (!isEditable && onToggle != null) + TextButton.icon( + onPressed: onToggle, + icon: Icon(toggleIcon, color: theme.colorScheme.primary), + label: Text( + toggleText, + style: TextStyle(color: theme.colorScheme.primary), + ), + ), + const SizedBox(height: toggleSpacing), + AnimatedCrossFade( + duration: animationDuration, + crossFadeState: isFormVisible + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: PaymentMethodForm( + key: const ValueKey('formVisible'), + isEditable: isEditable, + selectedType: selectedType, + onChanged: (_) {}, + initialData: data, + ), + secondChild: const SizedBox.shrink(key: ValueKey('formHidden')), + ), + SizedBox(height: isFormVisible ? formVisibleSpacing : formHiddenSpacing), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart new file mode 100644 index 0000000..c895ebf --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/payouts/single/adress_book/avatar.dart'; + + +class RecipientHeader extends StatelessWidget{ + final Recipient recipient; + + const RecipientHeader({super.key, required this.recipient}); + + final double _avatarRadius = 20; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: RecipientAvatar( + isVisible: false, + name: recipient.name, + avatarUrl: recipient.avatarUrl, + avatarRadius: _avatarRadius, + nameStyle: Theme.of(context).textTheme.bodyMedium, + ), + title: Text(recipient.name, style: theme.textTheme.titleLarge), + subtitle: Text(recipient.email, style: theme.textTheme.bodyLarge), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart new file mode 100644 index 0000000..9523db6 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/payout.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/dashboard/payouts/single/new_recipient/type.dart'; + + +class SinglePayout extends StatelessWidget { + final void Function(PaymentType type) onGoToPayment; + + static const double _cardPadding = 30.0; + static const double _dividerPaddingVertical = 12.0; + static const double _cardBorderRadius = 12.0; + static const double _dividerThickness = 1.0; + + const SinglePayout({super.key, required this.onGoToPayment}); + + @override + Widget build(BuildContext context) { + final paymentTypes = PaymentType.values; + final dividerColor = Theme.of(context).dividerColor; + + return SizedBox( + width: double.infinity, + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_cardBorderRadius), + ), + elevation: 4, + color: Theme.of(context).colorScheme.onSecondary, + child: Padding( + padding: const EdgeInsets.all(_cardPadding), + child: Column( + children: [ + for (int i = 0; i < paymentTypes.length; i++) ...[ + PaymentTypeTile( + type: paymentTypes[i], + onSelected: onGoToPayment, + ), + if (i < paymentTypes.length - 1) + Padding( + padding: const EdgeInsets.symmetric(vertical: _dividerPaddingVertical), + child: Divider(thickness: _dividerThickness, color: dividerColor), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart new file mode 100644 index 0000000..a62bc6b --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/new_recipient/type.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/icon.dart'; +import 'package:pweb/utils/payment/label.dart'; + + +class PaymentTypeTile extends StatelessWidget { + final PaymentType type; + final void Function(PaymentType type) onSelected; + + const PaymentTypeTile({ + super.key, + required this.type, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final label = getPaymentTypeLabel(context, type); + + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onSelected(type), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(iconForPaymentType(type), size: 24), + const SizedBox(width: 12), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/widget.dart new file mode 100644 index 0000000..b7c3198 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/widget.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/payouts/single/adress_book/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/single/new_recipient/payout.dart'; + + +class SinglePayoutForm extends StatelessWidget { + final ValueChanged onRecipientSelected; + final void Function(PaymentType type) onGoToPayment; + + const SinglePayoutForm({ + super.key, + required this.onRecipientSelected, + required this.onGoToPayment, + }); + + static const double _spacingBetweenAddressAndForm = 20.0; + static const double _bottomSpacing = 40.0; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AdressBookPayout(onSelected: onRecipientSelected), + const SizedBox(height: _spacingBetweenAddressAndForm), + SinglePayout(onGoToPayment: onGoToPayment), + const SizedBox(height: _bottomSpacing), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/errors/error.dart b/frontend/pweb/lib/pages/errors/error.dart new file mode 100644 index 0000000..3f898d1 --- /dev/null +++ b/frontend/pweb/lib/pages/errors/error.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/widgets/vspacer.dart'; +import 'package:pweb/utils/error_handler.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ErrorPage extends StatelessWidget { + final String title; + final String errorMessage; + final String errorHint; + + const ErrorPage({ + super.key, + required this.title, + required this.errorMessage, + required this.errorHint, + }); + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error), + const VSpacer(), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + const VSpacer(multiplier: 0.5), + ListTile( + title: Text(errorMessage, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge), + subtitle: Text(errorHint, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall), + ), + const VSpacer(multiplier: 1.5), + TextButton( + onPressed: () => navigate(context, Pages.root), + child: Text(AppLocalizations.of(context)!.goToMainPage), + ), + ], + ), + ), + ); +} + +Widget exceptionToErrorPage({ + required BuildContext context, + required String title, + required String errorMessage, + required Object exception, +}) => ErrorPage( + title: title, + errorMessage: errorMessage, + errorHint: ErrorHandler.handleError(context, exception), +); diff --git a/frontend/pweb/lib/pages/errors/not_found.dart b/frontend/pweb/lib/pages/errors/not_found.dart new file mode 100644 index 0000000..875aee7 --- /dev/null +++ b/frontend/pweb/lib/pages/errors/not_found.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/errors/error.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class NotFoundPage extends StatelessWidget { + const NotFoundPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + body: ErrorPage( + title: AppLocalizations.of(context)!.errorPageNotFoundTitle, + errorMessage: AppLocalizations.of(context)!.errorPageNotFoundMessage, + errorHint: AppLocalizations.of(context)!.errorPageNotFoundHint, + ), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/loader.dart b/frontend/pweb/lib/pages/loader.dart new file mode 100644 index 0000000..da16ffe --- /dev/null +++ b/frontend/pweb/lib/pages/loader.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/loaders/account.dart'; +import 'package:pweb/pages/loaders/permissions.dart'; + + +class PageViewLoader extends StatelessWidget { + final Widget child; + + const PageViewLoader({super.key, required this.child}); + + @override + Widget build(BuildContext context) => AccountLoader( + child: PermissionsLoader( + child: child, + ), + ); +} + diff --git a/frontend/pweb/lib/pages/loaders/account.dart b/frontend/pweb/lib/pages/loaders/account.dart new file mode 100644 index 0000000..8c78257 --- /dev/null +++ b/frontend/pweb/lib/pages/loaders/account.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AccountLoader extends StatelessWidget { + final Widget child; + + const AccountLoader({super.key, required this.child}); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + if (provider.error != null) { + postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: provider.error!, + ); + navigateAndReplace(context, Pages.login); + } + if ((provider.error == null) && (provider.account == null)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + provider.restore(); + }); + return const Center(child: CircularProgressIndicator()); + } + return child; + }); +} + diff --git a/frontend/pweb/lib/pages/loaders/organization.dart b/frontend/pweb/lib/pages/loaders/organization.dart new file mode 100644 index 0000000..3a3194f --- /dev/null +++ b/frontend/pweb/lib/pages/loaders/organization.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/organizations.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class OrganizationLoader extends StatelessWidget { + final Widget child; + + const OrganizationLoader({super.key, required this.child}); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + if (provider.error != null) { + postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: provider.error!, + ); + navigateAndReplace(context, Pages.login); + } + if ((provider.error == null) && (!provider.isOrganizationSet)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + provider.load(); + }); + return const Center(child: CircularProgressIndicator()); + } + return child; + }); +} diff --git a/frontend/pweb/lib/pages/loaders/permissions.dart b/frontend/pweb/lib/pages/loaders/permissions.dart new file mode 100644 index 0000000..1488048 --- /dev/null +++ b/frontend/pweb/lib/pages/loaders/permissions.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PermissionsLoader extends StatelessWidget { + final Widget child; + + const PermissionsLoader({super.key, required this.child}); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + if (provider.error != null) { + postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: provider.error!, + ); + navigateAndReplace(context, Pages.login); + } + if ((provider.error == null) && (provider.permissions.isEmpty)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + provider.load(); + }); + return const Center(child: CircularProgressIndicator()); + } + return child; + }); +} diff --git a/frontend/pweb/lib/pages/login/app_bar.dart b/frontend/pweb/lib/pages/login/app_bar.dart new file mode 100644 index 0000000..86c4977 --- /dev/null +++ b/frontend/pweb/lib/pages/login/app_bar.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/widgets/locale.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LoginAppBar extends StatelessWidget implements PreferredSizeWidget { + const LoginAppBar({super.key}); + + @override + Widget build(BuildContext context) => AppBar( + automaticallyImplyLeading: false, + actions: const [ + LocaleChangerDropdown(availableLocales: AppLocalizations.supportedLocales), + ], + ); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/frontend/pweb/lib/pages/login/buttons.dart b/frontend/pweb/lib/pages/login/buttons.dart new file mode 100644 index 0000000..1e72a77 --- /dev/null +++ b/frontend/pweb/lib/pages/login/buttons.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/login/login.dart'; +import 'package:pweb/pages/login/signup.dart'; +import 'package:pweb/widgets/hspacer.dart'; + + +class ButtonsRow extends StatelessWidget { + final Future Function() login; + final VoidCallback onSignUp; + final bool isEnabled; + + const ButtonsRow({ + super.key, + required this.login, + required this.onSignUp, + required this.isEnabled, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + LoginButton(onPressed: isEnabled ? () => login() : null), + SignupButton(onPressed: onSignUp), + HSpacer(multiplier: 0.25), + ], + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart new file mode 100644 index 0000000..e221ea6 --- /dev/null +++ b/frontend/pweb/lib/pages/login/form.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/pfe/provider.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/pages/login/buttons.dart'; +import 'package:pweb/pages/login/header.dart'; +import 'package:pweb/widgets/constrained_form.dart'; +import 'package:pweb/widgets/password/hint/short.dart'; +import 'package:pweb/widgets/password/password.dart'; +import 'package:pweb/widgets/username.dart'; +import 'package:pweb/widgets/vspacer.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LoginForm extends StatefulWidget { + const LoginForm({super.key}); + + @override + State createState() => _LoginFormState(); +} + +class _LoginFormState extends State { + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + + // ValueNotifiers for validation state + final ValueNotifier _isUsernameAcceptable = ValueNotifier(false); + final ValueNotifier _isPasswordAcceptable = ValueNotifier(false); + + Future _login(BuildContext context, VoidCallback onLogin, void Function(Object e) onError) async { + final pfeProvider = Provider.of(context, listen: false); + + try { + // final account = await pfeProvider.login( + // email: _usernameController.text, + // password: _passwordController.text, + // ); + onLogin(); + return 'ok'; + } catch (e) { + onError(pfeProvider.error == null ? e : pfeProvider.error!); + } + return null; + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _isUsernameAcceptable.dispose(); + _isPasswordAcceptable.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400, maxHeight: 300), + child: Card( + child: ConstrainedForm( + formKey: _formKey, + children: [ + const LoginHeader(), + const VSpacer(multiplier: 1.5), + UsernameField( + controller: _usernameController, + onValid: (isValid) => _isUsernameAcceptable.value = isValid, + ), + VSpacer(), + defaulRulesPasswordField( + context, + controller: _passwordController, + validationRuleBuilder: (rules, value) => shortValidation(context, rules, value), + onValid: (isValid) => _isPasswordAcceptable.value = isValid, + ), + VSpacer(multiplier: 2.0), + ValueListenableBuilder( + valueListenable: _isUsernameAcceptable, + builder: (context, isUsernameValid, child) => ValueListenableBuilder( + valueListenable: _isPasswordAcceptable, + builder: (context, isPasswordValid, child) => ButtonsRow( + onSignUp: () => navigate(context, Pages.signup), + login: () => _login( + context, + () => navigateAndReplace(context, Pages.sfactor), + (e) => postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorLogin, + exception: e, + ), + ), + isEnabled: isUsernameValid && isPasswordValid, + ), + ), + ), + ], + ), + ))); + } diff --git a/frontend/pweb/lib/pages/login/header.dart b/frontend/pweb/lib/pages/login/header.dart new file mode 100644 index 0000000..a875ab5 --- /dev/null +++ b/frontend/pweb/lib/pages/login/header.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:pweb/config/constants.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/widgets/hspacer.dart'; +import 'package:pweb/widgets/logo.dart'; + + +class LoginHeader extends StatelessWidget { + const LoginHeader({super.key}); + + @override + Widget build(BuildContext context) => Row( + children: [ + const ServiceLogo(size: 36), + const HSpacer(multiplier: 0.75), + Text( + '${AppConfig.appName} ${AppLocalizations.of(context)!.login}', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ); +} diff --git a/frontend/pweb/lib/pages/login/login.dart b/frontend/pweb/lib/pages/login/login.dart new file mode 100644 index 0000000..ba7c4e8 --- /dev/null +++ b/frontend/pweb/lib/pages/login/login.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/widgets/vspacer.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LoginButton extends StatelessWidget { + final VoidCallback? onPressed; + + const LoginButton({ + super.key, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) => ElevatedButton( + onPressed: provider.isLoading ? null : onPressed, + child: + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (provider.isLoading) + ...[ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(), + ), + VSpacer(multiplier: 0.25), + ], + Text(AppLocalizations.of(context)!.login), + ], + ), + )); +} diff --git a/frontend/pweb/lib/pages/login/page.dart b/frontend/pweb/lib/pages/login/page.dart new file mode 100644 index 0000000..177f148 --- /dev/null +++ b/frontend/pweb/lib/pages/login/page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/login/app_bar.dart'; +import 'package:pweb/pages/login/form.dart'; +import 'package:pweb/pages/with_footer.dart'; + + +class LoginPage extends StatelessWidget { + const LoginPage({super.key}); + + @override + Widget build(BuildContext context) => PageWithFooter( + appBar: const LoginAppBar(), + child: LoginForm(), + ); +} diff --git a/frontend/pweb/lib/pages/login/signup.dart b/frontend/pweb/lib/pages/login/signup.dart new file mode 100644 index 0000000..f7e36e8 --- /dev/null +++ b/frontend/pweb/lib/pages/login/signup.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignupButton extends StatelessWidget { + final VoidCallback? onPressed; + + const SignupButton({ + super.key, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: Text(AppLocalizations.of(context)!.signup), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/add/card.dart b/frontend/pweb/lib/pages/payment_methods/add/card.dart new file mode 100644 index 0000000..a59ee5c --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/card.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_multi_formatter/flutter_multi_formatter.dart'; + +import 'package:pshared/models/payment/methods/card.dart'; + +import 'package:pweb/utils/text_field_styles.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class CardFormMinimal extends StatefulWidget { + final void Function(CardPaymentMethod) onChanged; + final CardPaymentMethod? initialData; + final bool isEditable; + + const CardFormMinimal({ + super.key, + required this.onChanged, + this.initialData, + required this.isEditable, + }); + + @override + State createState() => _CardFormMinimalState(); +} + +class _CardFormMinimalState extends State { + final _formKey = GlobalKey(); + late TextEditingController _panController; + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + + @override + void initState() { + super.initState(); + _panController = TextEditingController(text: widget.initialData?.pan ?? ''); + _firstNameController = TextEditingController(text: widget.initialData?.firstName ?? ''); + _lastNameController = TextEditingController(text: widget.initialData?.lastName ?? ''); + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid()); + } + + void _emitIfValid() { + if (_formKey.currentState?.validate() ?? false) { + widget.onChanged( + CardPaymentMethod( + pan: _panController.text.replaceAll(' ', ''), + firstName: _firstNameController.text, + lastName: _lastNameController.text, + ), + ); + } + } + + @override + void didUpdateWidget(covariant CardFormMinimal oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialData == null && oldWidget.initialData != null) { + _panController.clear(); + _firstNameController.clear(); + _lastNameController.clear(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Form( + key: _formKey, + onChanged: _emitIfValid, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + readOnly: !widget.isEditable, + controller: _panController, + decoration: getInputDecoration(context, l10n.cardNumber, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + keyboardType: TextInputType.number, + inputFormatters: [CreditCardNumberInputFormatter()], + validator: (v) => (v == null || v.replaceAll(' ', '').length < 12) ? l10n.enterCardNumber : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _firstNameController, + decoration: getInputDecoration(context, l10n.firstName, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (v) => (v == null || v.isEmpty) ? l10n.enterFirstName : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _lastNameController, + decoration: getInputDecoration(context, l10n.lastName, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (v) => (v == null || v.isEmpty) ? l10n.enterLastName : null, + ), + ], + ), + ); + } + + @override + void dispose() { + _panController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/iban.dart b/frontend/pweb/lib/pages/payment_methods/add/iban.dart new file mode 100644 index 0000000..cf76227 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/iban.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/iban.dart'; + +import 'package:pweb/utils/text_field_styles.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class IbanForm extends StatefulWidget { + final void Function(IbanPaymentMethod) onChanged; + final IbanPaymentMethod? initialData; + final bool isEditable; + + const IbanForm({ + super.key, + required this.onChanged, + this.initialData, + required this.isEditable, + }); + + @override + State createState() => _IbanFormState(); +} + +class _IbanFormState extends State { + final _formKey = GlobalKey(); + late TextEditingController _ibanController; + late TextEditingController _accountHolderController; + late TextEditingController _bicController; + late TextEditingController _bankNameController; + + @override + void initState() { + super.initState(); + _ibanController = TextEditingController(text: widget.initialData?.iban ?? ''); + _accountHolderController = TextEditingController(text: widget.initialData?.accountHolder ?? ''); + _bicController = TextEditingController(text: widget.initialData?.bic ?? ''); + _bankNameController = TextEditingController(text: widget.initialData?.bankName ?? ''); + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid()); + } + + void _emitIfValid() { + if (_formKey.currentState?.validate() ?? false) { + widget.onChanged( + IbanPaymentMethod( + iban: _ibanController.text, + accountHolder: _accountHolderController.text, + bic: _bicController.text, + bankName: _bankNameController.text, + ), + ); + } + } + + @override + void didUpdateWidget(covariant IbanForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialData == null && oldWidget.initialData != null) { + _ibanController.clear(); + _accountHolderController.clear(); + _bicController.clear(); + _bankNameController.clear(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Form( + key: _formKey, + onChanged: _emitIfValid, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + readOnly: !widget.isEditable, + controller: _ibanController, + decoration: getInputDecoration(context, l10n.iban, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterIban : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _accountHolderController, + decoration: getInputDecoration(context, l10n.accountHolder, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterAccountHolder : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _bicController, + decoration: getInputDecoration(context, l10n.bic, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterBic : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _bankNameController, + decoration: getInputDecoration(context, l10n.bankName, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterBankName : null, + ), + ], + ), + ); + } + + @override + void dispose() { + _ibanController.dispose(); + _accountHolderController.dispose(); + _bicController.dispose(); + _bankNameController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart b/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart new file mode 100644 index 0000000..9411619 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/method_selector.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/utils/payment/label.dart'; + + +class PaymentMethodTypeSelector extends StatelessWidget { + final PaymentType? value; + final ValueChanged onChanged; + + const PaymentMethodTypeSelector({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return DropdownButtonFormField( + value: value, + decoration: InputDecoration(labelText: l10n.paymentType), + items: PaymentType.values.map((type) { + final label = getPaymentTypeLabel(context, type); + return DropdownMenuItem(value: type, child: Text(label)); + }).toList(), + onChanged: onChanged, + validator: (val) => val == null ? l10n.selectPaymentType : null, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/add/russian_bank.dart b/frontend/pweb/lib/pages/payment_methods/add/russian_bank.dart new file mode 100644 index 0000000..a6c944d --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/russian_bank.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/russian_bank.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/utils/text_field_styles.dart'; + + +class RussianBankForm extends StatefulWidget { + final void Function(RussianBankAccountPaymentMethod) onChanged; + final RussianBankAccountPaymentMethod? initialData; + final bool isEditable; + + const RussianBankForm({ + super.key, + required this.onChanged, + this.initialData, + required this.isEditable, + }); + + @override + State createState() => _RussianBankFormState(); +} + +class _RussianBankFormState extends State { + final _formKey = GlobalKey(); + + late final TextEditingController _recipientNameController; + late final TextEditingController _innController; + late final TextEditingController _kppController; + late final TextEditingController _bankNameController; + late final TextEditingController _bikController; + late final TextEditingController _accountNumberController; + late final TextEditingController _correspondentAccountController; + + @override + void initState() { + super.initState(); + _recipientNameController = TextEditingController(text: widget.initialData?.recipientName ?? ''); + _innController = TextEditingController(text: widget.initialData?.inn ?? ''); + _kppController = TextEditingController(text: widget.initialData?.kpp ?? ''); + _bankNameController = TextEditingController(text: widget.initialData?.bankName ?? ''); + _bikController = TextEditingController(text: widget.initialData?.bik ?? ''); + _accountNumberController = TextEditingController(text: widget.initialData?.accountNumber ?? ''); + _correspondentAccountController = TextEditingController(text: widget.initialData?.correspondentAccount ?? ''); + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid()); + } + + void _emitIfValid() { + if (_formKey.currentState?.validate() ?? false) { + widget.onChanged( + RussianBankAccountPaymentMethod( + recipientName: _recipientNameController.text, + inn: _innController.text, + kpp: _kppController.text, + bankName: _bankNameController.text, + bik: _bikController.text, + accountNumber: _accountNumberController.text, + correspondentAccount: _correspondentAccountController.text, + ), + ); + } + } + + @override + void didUpdateWidget(covariant RussianBankForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialData == null && oldWidget.initialData != null) { + _recipientNameController.clear(); + _innController.clear(); + _kppController.clear(); + _bankNameController.clear(); + _bikController.clear(); + _accountNumberController.clear(); + _correspondentAccountController.clear(); + } + } + + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Form( + key: _formKey, + onChanged: _emitIfValid, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + readOnly: !widget.isEditable, + controller: _recipientNameController, + decoration: getInputDecoration(context, l10n.recipientName, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterRecipientName : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _innController, + decoration: getInputDecoration(context, l10n.inn, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterInn : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _kppController, + decoration: getInputDecoration(context, l10n.kpp, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterKpp : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _bankNameController, + decoration: getInputDecoration(context, l10n.bankName, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterBankName : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _bikController, + decoration: getInputDecoration(context, l10n.bik, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterBik : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _accountNumberController, + decoration: getInputDecoration(context, l10n.accountNumber, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterAccountNumber : null, + ), + const SizedBox(height: 12), + TextFormField( + readOnly: !widget.isEditable, + controller: _correspondentAccountController, + decoration: getInputDecoration(context, l10n.correspondentAccount, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + validator: (val) => (val == null || val.isEmpty) ? l10n.enterCorrespondentAccount : null, + ), + ], + ), + ); + } + + @override + void dispose() { + _recipientNameController.dispose(); + _innController.dispose(); + _kppController.dispose(); + _bankNameController.dispose(); + _bikController.dispose(); + _accountNumberController.dispose(); + _correspondentAccountController.dispose(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/wallet.dart b/frontend/pweb/lib/pages/payment_methods/add/wallet.dart new file mode 100644 index 0000000..28d136e --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/wallet.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/wallet.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/utils/text_field_styles.dart'; + + +class WalletForm extends StatefulWidget { + final void Function(WalletPaymentMethod) onChanged; + final WalletPaymentMethod? initialData; + final bool isEditable; + + const WalletForm({ + super.key, + required this.onChanged, + this.initialData, + required this.isEditable, + }); + + @override + State createState() => _WalletFormState(); +} + +class _WalletFormState extends State { + late TextEditingController _walletIdController; + + @override + void initState() { + super.initState(); + _walletIdController = TextEditingController(text: widget.initialData?.walletId); + } + + void _emit() { + if (_walletIdController.text.isNotEmpty) { + widget.onChanged(WalletPaymentMethod(walletId: _walletIdController.text)); + } else { + } + } + + @override + void didUpdateWidget(covariant WalletForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialData == null && oldWidget.initialData != null) { + _walletIdController.clear(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return TextFormField( + readOnly: !widget.isEditable, + controller: _walletIdController, + decoration: getInputDecoration(context, l10n.walletId, widget.isEditable), + style: getTextFieldStyle(context, widget.isEditable), + onChanged: (_) => _emit(), + validator: (val) => (val?.isEmpty ?? true) ? l10n.enterWalletId : null, + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/add/widget.dart b/frontend/pweb/lib/pages/payment_methods/add/widget.dart new file mode 100644 index 0000000..a1f3e90 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/add/widget.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/add/method_selector.dart'; +import 'package:pweb/pages/payment_methods/form.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AddPaymentMethodDialog extends StatefulWidget { + const AddPaymentMethodDialog({super.key}); + + @override + State createState() => _AddPaymentMethodDialogState(); +} + +class _AddPaymentMethodDialogState extends State { + final _formKey = GlobalKey(); + PaymentType? _selectedType; + + // Holds current result from the selected form + Object? _currentMethod; + + void _submit() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + if (_currentMethod case final Object method) { + Navigator.of(context).pop(method); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return AlertDialog( + title: Text(l10n.addPaymentMethod), + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PaymentMethodTypeSelector( + value: _selectedType, + onChanged: (val) => setState(() { + _selectedType = val; + _currentMethod = null; + }), + ), + const SizedBox(height: 16), + if (_selectedType != null) + PaymentMethodForm( + selectedType: _selectedType, + onChanged: (val) => _currentMethod = val, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: _submit, + child: Text(l10n.add), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/delete_confirmation.dart b/frontend/pweb/lib/pages/payment_methods/delete_confirmation.dart new file mode 100644 index 0000000..290ab18 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/delete_confirmation.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + +Future showDeleteConfirmationDialog(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.delete), + content: Text(l10n.deletePaymentConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.delete), + ), + ], + ), + ) ?? false; +} diff --git a/frontend/pweb/lib/pages/payment_methods/form.dart b/frontend/pweb/lib/pages/payment_methods/form.dart new file mode 100644 index 0000000..0010913 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/form.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/card.dart'; +import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/russian_bank.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/pages/payment_methods/add/card.dart'; +import 'package:pweb/pages/payment_methods/add/iban.dart'; +import 'package:pweb/pages/payment_methods/add/russian_bank.dart'; +import 'package:pweb/pages/payment_methods/add/wallet.dart'; + + +class PaymentMethodForm extends StatelessWidget { + final PaymentType? selectedType; + final ValueChanged onChanged; + final Object? initialData; + final bool isEditable; + + const PaymentMethodForm({ + super.key, + required this.selectedType, + required this.onChanged, + this.initialData, + this.isEditable = true, + }); + + @override + Widget build(BuildContext context) { + return switch (selectedType) { + PaymentType.card => CardFormMinimal( + onChanged: onChanged, + initialData: initialData as CardPaymentMethod?, + isEditable: isEditable, + ), + PaymentType.iban => IbanForm( + onChanged: onChanged, + initialData: initialData as IbanPaymentMethod?, + isEditable: isEditable, + ), + PaymentType.wallet => WalletForm( + onChanged: onChanged, + initialData: initialData as WalletPaymentMethod?, + isEditable: isEditable, + ), + PaymentType.bankAccount => RussianBankForm( + onChanged: onChanged, + initialData: initialData as RussianBankAccountPaymentMethod?, + isEditable: isEditable, + ), + _ => const SizedBox.shrink(), + }; + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/icon.dart b/frontend/pweb/lib/pages/payment_methods/icon.dart new file mode 100644 index 0000000..faab1be --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/icon.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + + +IconData iconForPaymentType(PaymentType type) { + switch (type) { + case PaymentType.bankAccount: + return Icons.account_balance; + case PaymentType.iban: + return Icons.language; + case PaymentType.wallet: + return Icons.account_balance_wallet; + case PaymentType.card: + return Icons.credit_card; + } +} diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart new file mode 100644 index 0000000..5f0f861 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -0,0 +1,232 @@ +import 'package:amplitude_flutter/amplitude.dart'; +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/pages/dashboard/payouts/payment_form.dart'; +import 'package:pweb/pages/dashboard/payouts/single/form/details.dart'; +import 'package:pweb/pages/dashboard/payouts/single/form/header.dart'; +import 'package:pweb/providers/payment_methods.dart'; +import 'package:pweb/providers/recipient.dart'; +import 'package:pweb/services/amplitude.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/utils/payment/dropdown.dart'; +import 'package:pweb/utils/payment/selector_type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +//TODO: decide whether to make AppDimensions universal for the whole app or leave it as it is - unique for this page alone + + +class PaymentPage extends StatefulWidget { + final PaymentType? type; + final ValueChanged? onBack; + + const PaymentPage({super.key, this.type, this.onBack}); + + @override + State createState() => _PaymentPageState(); +} + +class _PaymentPageState extends State { + late Map _availableTypes; + late PaymentType _selectedType; + bool _isFormVisible = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final recipientProvider = context.watch(); + final methodsProvider = context.watch(); + final recipient = recipientProvider.selectedRecipient; + + // Initialize available types based on whether we have a recipient + if (recipient != null) { + // We have a recipient - use their payment methods + _availableTypes = { + if (recipient.card != null) PaymentType.card: recipient.card!, + if (recipient.iban != null) PaymentType.iban: recipient.iban!, + if (recipient.wallet != null) PaymentType.wallet: recipient.wallet!, + if (recipient.bank != null) PaymentType.bankAccount: recipient.bank!, + }; + + // Set selected type if it's available, otherwise use first available type + if (_availableTypes.containsKey(_selectedType)) { + // Keep current selection if valid + } else if (_availableTypes.isNotEmpty) { + _selectedType = _availableTypes.keys.first; + } else { + // Fallback if recipient has no payment methods + _selectedType = PaymentType.bankAccount; + } + } else { + // No recipient - we're creating a new payment from scratch + _availableTypes = {}; + _selectedType = widget.type ?? PaymentType.bankAccount; + _isFormVisible = true; // Always show form when creating new payment + } + + // Load payment methods if not already loaded + if (methodsProvider.methods.isEmpty && !methodsProvider.isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + methodsProvider.loadMethods(); + }); + } + } + + @override + void initState() { + super.initState(); + // Initial values + _availableTypes = {}; + _selectedType = widget.type ?? PaymentType.bankAccount; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dimensions = AppDimensions(); + final recipientProvider = context.watch(); + final methodsProvider = context.watch(); + final recipient = recipientProvider.selectedRecipient; + + // Show loading state for payment methods + if (methodsProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + // Show error state for payment methods + if (methodsProvider.error != null) { + return Center( + child: Text('Error: ${methodsProvider.error}'), + ); + } + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), + child: Material( + elevation: dimensions.elevationSmall, + borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), + color: theme.colorScheme.onSecondary, + child: Padding( + padding: EdgeInsets.all(dimensions.paddingLarge), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button + Align( + alignment: Alignment.topLeft, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + widget.onBack?.call(recipient); + }, + ), + ), + SizedBox(height: dimensions.paddingSmall), + + // Header + Row( + children: [ + Icon( + Icons.send_rounded, + color: theme.colorScheme.primary, + size: dimensions.iconSizeLarge + ), + SizedBox(width: dimensions.spacingSmall), + Text( + AppLocalizations.of(context)!.sendTo, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold + ), + ), + ], + ), + SizedBox(height: dimensions.paddingXXLarge), + + // Payment method dropdown (user's payment methods) + PaymentMethodDropdown( + methods: methodsProvider.methods, + initialValue: methodsProvider.selectedMethod, + onChanged: (method) { + methodsProvider.selectMethod(method); + }, + ), + SizedBox(height: dimensions.paddingXLarge), + + // Recipient section (only show if we have a recipient) + if (recipient != null) ...[ + RecipientHeader(recipient: recipient), + SizedBox(height: dimensions.paddingMedium), + + // Payment type selector (recipient's payment methods) + if (_availableTypes.isNotEmpty) + PaymentTypeSelector( + availableTypes: _availableTypes, + selectedType: _selectedType, + onSelected: (type) => setState(() => _selectedType = type), + ), + SizedBox(height: dimensions.paddingMedium), + ], + + // Payment details section + PaymentDetailsSection( + isFormVisible: recipient == null || _isFormVisible, + onToggle: recipient != null + ? () => setState(() => _isFormVisible = !_isFormVisible) + : null, // No toggle when creating new payment + selectedType: _selectedType, + data: _availableTypes[_selectedType], + isEditable: recipient == null, + ), + + const PaymentFormWidget(), + + SizedBox(height: dimensions.paddingXXXLarge), + + Center( + child: SizedBox( + width: dimensions.buttonWidth, + height: dimensions.buttonHeight, + child: InkWell( + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + onTap: () => + // TODO: Handle Payment logic + AmplitudeService.pageOpened(PayoutDestination.payment), //TODO: replace with payment event + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + ), + child: Center( + child: Text( + AppLocalizations.of(context)!.send, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + SizedBox(height: dimensions.paddingLarge), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/title.dart b/frontend/pweb/lib/pages/payment_methods/title.dart new file mode 100644 index 0000000..54136fd --- /dev/null +++ b/frontend/pweb/lib/pages/payment_methods/title.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/payment_methods/icon.dart'; +import 'package:pshared/models/payment/methods/type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +class PaymentMethodTile extends StatelessWidget { + const PaymentMethodTile({ + super.key, + required this.method, + required this.index, + required this.makeMain, + required this.toggleEnabled, + required this.edit, + required this.delete, + }); + + final PaymentMethod method; + final int index; + final VoidCallback makeMain; + final ValueChanged toggleEnabled; + final VoidCallback edit; + final VoidCallback delete; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Opacity( + opacity: method.isEnabled ? 1 : 0.5, + child: Card( + margin: const EdgeInsets.symmetric(vertical: 4), + elevation: 0, + child: ListTile( + key: ValueKey(method.id), + leading: Icon(iconForPaymentType(method.type)), + onTap: makeMain, + title: Row( + children: [ + Expanded(child: Text(method.label)), + Text( + method.details, + style: theme.textTheme.bodySmall, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMakeMainButton(context), + _buildEnabledSwitch(), + _buildPopupMenu(l10n), + ], + ), + ), + ), + ); + } + + Widget _buildMakeMainButton(BuildContext context) { + final theme = Theme.of(context); + return IconButton( + tooltip: 'Make main', + icon: Icon( + method.isMain ? Icons.star : Icons.star_outline, + color: method.isMain ? theme.colorScheme.primary : null, + ), + onPressed: makeMain, + ); + } + + Widget _buildEnabledSwitch() { + return Switch.adaptive( + value: method.isEnabled, + onChanged: toggleEnabled, + ); + } + + Widget _buildPopupMenu(AppLocalizations l10n) { + return PopupMenuButton( + tooltip: l10n.moreActions, + onSelected: (value) { + switch (value) { + case 'edit': + edit(); + break; + case 'delete': + delete(); + break; + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'edit', child: Text(l10n.edit)), + PopupMenuItem(value: 'delete', child: Text(l10n.delete)), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/methods/advanced.dart b/frontend/pweb/lib/pages/payment_page/methods/advanced.dart new file mode 100644 index 0000000..cf9256f --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/methods/advanced.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentConfigAdvanced extends StatelessWidget { + const PaymentConfigAdvanced({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return ExpansionTile( + title: Text(l10n.advanced), + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [Text(l10n.fallbackExplanation)], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/methods/controller.dart b/frontend/pweb/lib/pages/payment_page/methods/controller.dart new file mode 100644 index 0000000..e0496f4 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/methods/controller.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/methods/type.dart'; + +import 'package:pweb/providers/payment_methods.dart'; +import 'package:pweb/pages/payment_methods/add/widget.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentConfigController { + final BuildContext context; + + PaymentConfigController(this.context); + + void loadMethods() { + context.read().loadMethods(); + } + + Future addMethod() async { + await showDialog( + context: context, + builder: (_) => const AddPaymentMethodDialog(), + ); + loadMethods(); + } + + Future editMethod(PaymentMethod method) async { + // TODO: implement edit functionality + } + + Future deleteMethod(PaymentMethod method) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.delete), + content: Text(l10n.deletePaymentConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.delete), + ), + ], + ), + ); + + if (confirmed == true) { + context.read().deleteMethod(method); + } + } + + void toggleEnabled(PaymentMethod method, bool value) { + context.read().toggleEnabled(method, value); + } + + void makeMain(PaymentMethod method) { + context.read().makeMain(method); + } + + void reorder(int oldIndex, int newIndex) { + context.read().reorderMethods(oldIndex, newIndex); + } +} diff --git a/frontend/pweb/lib/pages/payment_page/methods/header.dart b/frontend/pweb/lib/pages/payment_page/methods/header.dart new file mode 100644 index 0000000..0c935ed --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/methods/header.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentConfigHeader extends StatelessWidget { + final VoidCallback onAdd; + const PaymentConfigHeader({super.key, required this.onAdd}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Column( + children: [ + Text( + l10n.paymentConfigTitle, + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text(l10n.paymentConfigSubtitle, textAlign: TextAlign.center), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text(l10n.addPaymentMethod), + onPressed: onAdd, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payment_page/methods/list.dart b/frontend/pweb/lib/pages/payment_page/methods/list.dart new file mode 100644 index 0000000..3c98471 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/methods/list.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/pages/payment_methods/title.dart'; +import 'package:pweb/pages/payment_page/methods/controller.dart'; +import 'package:pweb/providers/payment_methods.dart'; + + +class PaymentConfigList extends StatelessWidget { + final PaymentConfigController controller; + const PaymentConfigList({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: provider.methods.length, + onReorder: controller.reorder, + itemBuilder: (context, index) { + final method = provider.methods[index]; + return ReorderableDragStartListener( + key: Key(method.id), + index: index, + child: PaymentMethodTile( + method: method, + index: index, + makeMain: () => controller.makeMain(method), + toggleEnabled: (v) => controller.toggleEnabled(method, v), + edit: () => controller.editMethod(method), + delete: () => controller.deleteMethod(method), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/methods/widget.dart b/frontend/pweb/lib/pages/payment_page/methods/widget.dart new file mode 100644 index 0000000..5ae0623 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/methods/widget.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/payment_page/methods/advanced.dart'; +import 'package:pweb/pages/payment_page/methods/controller.dart'; +import 'package:pweb/pages/payment_page/methods/header.dart'; +import 'package:pweb/pages/payment_page/methods/list.dart'; + + +class MethodsWidget extends StatefulWidget { + const MethodsWidget({super.key}); + + @override + State createState() => _MethodsWidgetState(); +} + +class _MethodsWidgetState extends State { + late final PaymentConfigController controller; + + @override + void initState() { + super.initState(); + controller = PaymentConfigController(context); + controller.loadMethods(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: theme.cardTheme.elevation ?? 4, + color: theme.colorScheme.onSecondary, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PaymentConfigHeader(onAdd: controller.addMethod), + const SizedBox(height: 12), + PaymentConfigList(controller: controller), + const SizedBox(height: 12), + const PaymentConfigAdvanced(), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/page.dart b/frontend/pweb/lib/pages/payment_page/page.dart new file mode 100644 index 0000000..2898ff7 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/page.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/models/wallet.dart'; + +import 'package:pweb/pages/payment_page/methods/widget.dart'; +import 'package:pweb/pages/payment_page/wallet/wigets.dart'; +import 'package:pweb/providers/payment_methods.dart'; + + +class PaymentConfigPage extends StatelessWidget { + final Function(Wallet) onWalletTap; + + const PaymentConfigPage({super.key, required this.onWalletTap}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.error != null) { + return Center(child: Text('Error: ${provider.error}')); + } + + return Column( + children: [ + MethodsWidget(), + Expanded( + child: WalletWidgets(onWalletTap: onWalletTap), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/card.dart b/frontend/pweb/lib/pages/payment_page/wallet/card.dart new file mode 100644 index 0000000..6341477 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/card.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; +import 'package:pweb/providers/wallets.dart'; +import 'package:pweb/utils/currency.dart'; + + +class WalletCard extends StatelessWidget { + final Wallet wallet; + final VoidCallback onTap; + + const WalletCard({super.key, required this.wallet, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: theme.cardTheme.elevation ?? 4, + color: theme.colorScheme.onSecondary, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Container( + padding: const EdgeInsets.only(left: 50, top: 16, bottom: 16), + child: Row( + spacing: 3, + children: [ + CircleAvatar( + radius: 24, + child: Icon(iconForCurrencyType(wallet.currency), size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BalanceAmount( + wallet: wallet, + onToggleVisibility: () { + context.read().toggleVisibility(wallet.id); + }, + ), + Text( + wallet.name, + style: theme.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/buttons.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/buttons.dart new file mode 100644 index 0000000..a9aad41 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/buttons.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pweb/pages/payment_page/wallet/edit/buttons/send.dart'; +import 'package:pweb/pages/payment_page/wallet/edit/buttons/top_up.dart'; +import 'package:pweb/providers/wallets.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class ButtonsWalletWidget extends StatelessWidget { + const ButtonsWalletWidget({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final wallet = provider.wallets?.first; + + if (wallet == null) return const SizedBox.shrink(); + + final dimensions = AppDimensions(); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceBright, + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.primary.withAlpha(50), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: SendPayoutButton(), + ), + VerticalDivider( + color: Theme.of(context).colorScheme.primary, + thickness: 1, + width: 10, + ), + Expanded( + child: TopUpButton(), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/save.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/save.dart new file mode 100644 index 0000000..8d64fea --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/save.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class SaveWalletButton extends StatelessWidget { + final Wallet wallet; + final TextEditingController nameController; + final TextEditingController balanceController; + final VoidCallback onSave; // Changed to VoidCallback + + const SaveWalletButton({ + super.key, + required this.wallet, + required this.nameController, + required this.balanceController, + required this.onSave, // Now matches _saveWallet signature + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dimensions = AppDimensions(); + + return Center( + child: SizedBox( + width: dimensions.buttonWidth, + height: dimensions.buttonHeight, + child: InkWell( + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + onTap: onSave, // Directly use onSave now + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), + ), + child: Center( + child: Text( + 'Save', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/send.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/send.dart new file mode 100644 index 0000000..daae32f --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/send.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/providers/wallets.dart'; + + +class SendPayoutButton extends StatelessWidget { + + const SendPayoutButton({ + super.key, + }); + + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + shadowColor: null, + elevation: 0, + ), + onPressed: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add functionality')), + ), + child: Text('Send Payout'), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/top_up.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/top_up.dart new file mode 100644 index 0000000..898fdb9 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/buttons/top_up.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + + +class TopUpButton extends StatelessWidget{ + const TopUpButton({super.key}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + shadowColor: null, + elevation: 0, + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Add functionality')), + ); + }, + child: Text('Top Up Balance'), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/fields.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/fields.dart new file mode 100644 index 0000000..9462336 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/fields.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/amount.dart'; + +import 'package:pweb/providers/wallets.dart'; +import 'package:pweb/utils/currency.dart'; + + +class WalletEditFields extends StatelessWidget { + + const WalletEditFields({super.key}); + + @override + Widget build(BuildContext context) { + final wallet = context.watch().wallets?.first; + + if (wallet == null) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + BalanceAmount( + wallet: wallet, + onToggleVisibility: () { + context.read().toggleVisibility(wallet.id); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(wallet.walletUserID, style: Theme.of(context).textTheme.bodyLarge), + IconButton( + icon: Icon(Icons.copy), + iconSize: 18, + onPressed: () => Clipboard.setData(ClipboardData(text: wallet.walletUserID)), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/header.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/header.dart new file mode 100644 index 0000000..113aa18 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/header.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/utils/currency.dart'; +import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/providers/wallets.dart'; + + +// class WalletEditHeader extends StatefulWidget { +// const WalletEditHeader({super.key}); + +// @override +// State createState() => _WalletEditHeaderState(); +// } + +// class _WalletEditHeaderState extends State { +// bool _isEditing = false; +// late TextEditingController _controller; + +// @override +// void initState() { +// super.initState(); +// _controller = TextEditingController(); +// } + +// @override +// void dispose() { +// _controller.dispose(); +// super.dispose(); +// } + +// @override +// Widget build(BuildContext context) { +// final provider = context.watch(); +// final currentWallet = provider.getWalletById(provider.wallets!.id); + + +// if (wallet == null) { +// return const SizedBox.shrink(); +// } + +// final theme = Theme.of(context); +// final dimensions = AppDimensions(); + +// if (!_isEditing) { +// _controller.text = wallet.name; +// } + +// return Row( +// spacing: 8, +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// Icon( +// iconForCurrencyType(wallet.currency), +// color: theme.colorScheme.primary, +// size: dimensions.iconSizeLarge, +// ), + +// Expanded( +// child: !_isEditing +// ? Row( +// children: [ +// Expanded( +// child: Text( +// wallet.name, +// style: theme.textTheme.headlineMedium!.copyWith( +// fontWeight: FontWeight.bold,), +// ), +// ), +// IconButton( +// icon: const Icon(Icons.edit), +// onPressed: () { +// setState(() { +// _isEditing = true; +// }); +// }, +// ), +// ], +// ) +// : Row( +// children: [ +// Expanded( +// child: TextFormField( +// controller: _controller, +// decoration: const InputDecoration( +// border: OutlineInputBorder(), +// isDense: true, +// hintText: 'Wallet name', +// ), +// ), +// ), +// IconButton( +// icon: const Icon(Icons.check), +// color: theme.colorScheme.primary, +// onPressed: () async { +// provider.updateName(wallet.id, _controller.text); +// await provider.updateWallet(wallet.copyWith(name: _controller.text)); +// ScaffoldMessenger.of(context).showSnackBar( +// const SnackBar(content: Text('Wallet name saved')), +// ); +// setState(() { +// _isEditing = false; +// }); +// }, +// ), +// IconButton( +// icon: const Icon(Icons.close), +// onPressed: () { +// _controller.text = wallet.name; +// setState(() { +// _isEditing = false; +// }); +// }, +// ), +// ], +// ), +// ), +// ], +// ); +// } +// } \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/edit/page.dart b/frontend/pweb/lib/pages/payment_page/wallet/edit/page.dart new file mode 100644 index 0000000..e50bc3d --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/edit/page.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/pages/payment_page/wallet/edit/buttons/buttons.dart'; +import 'package:pweb/pages/payment_page/wallet/edit/fields.dart'; +import 'package:pweb/utils/dimensions.dart'; + + +class WalletEditPage extends StatelessWidget { + final Wallet wallet; + final VoidCallback onBack; + + const WalletEditPage({super.key, required this.wallet, required this.onBack}); + + @override + Widget build(BuildContext context) { + final dimensions = AppDimensions(); + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), + child: Material( + elevation: dimensions.elevationSmall, + color: Theme.of(context).colorScheme.onSecondary, + borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), + child: Padding( + padding: EdgeInsets.all(dimensions.paddingLarge), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + + // WalletEditHeader(), + + WalletEditFields(), + + const SizedBox(height: 24), + + ButtonsWalletWidget(), + + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_page/wallet/wigets.dart b/frontend/pweb/lib/pages/payment_page/wallet/wigets.dart new file mode 100644 index 0000000..66901e8 --- /dev/null +++ b/frontend/pweb/lib/pages/payment_page/wallet/wigets.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:pweb/models/wallet.dart'; + +import 'package:pweb/pages/payment_page/wallet/card.dart'; +import 'package:pweb/providers/wallets.dart'; + + +class WalletWidgets extends StatelessWidget { + final Function(Wallet) onWalletTap; + + const WalletWidgets({super.key, required this.onWalletTap}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + final wallets = provider.wallets; + + if (wallets == null) { + return const Center(child: CircularProgressIndicator()); + } + + return GridView.builder( + scrollDirection: Axis.vertical, + physics: AlwaysScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 3, + ), + itemCount: wallets.length, + itemBuilder: (context, index) { + final wallet = wallets[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: WalletCard( + wallet: wallet, + onTap: () { + onWalletTap(wallet); + }, + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/charts/distribution.dart b/frontend/pweb/lib/pages/report/charts/distribution.dart new file mode 100644 index 0000000..b491985 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/distribution.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'package:syncfusion_flutter_charts/charts.dart'; + +import 'package:pshared/models/payment/operation.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PayoutDistributionChart extends StatelessWidget { + final List operations; + const PayoutDistributionChart({super.key, required this.operations}); + + @override + Widget build(BuildContext context) { + // 1) Aggregate sums + final sums = {}; + for (var op in operations) { + final name = op.name ?? AppLocalizations.of(context)!.unknown; + sums[name] = (sums[name] ?? 0) + op.amount; + } + if (sums.isEmpty) { + return Center(child: Text(AppLocalizations.of(context)!.noPayouts)); + } + + // 2) Build chart data + final data = sums.entries + .map((e) => _ChartData(e.key, e.value)) + .toList(); + + // 3) Build a simple horizontal legend + final palette = [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + Theme.of(context).colorScheme.tertiary ?? Colors.grey, + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context).colorScheme.secondaryContainer, + ]; + final legendItems = List.generate(data.length, (i) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 10, color: palette[i % palette.length]), + const SizedBox(width: 4), + Text(data[i].label, style: Theme.of(context).textTheme.bodySmall), + if (i < data.length - 1) const SizedBox(width: 12), + ], + ); + }); + + return Card( + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Pie takes 2/3 of the width + Expanded( + flex: 2, + child: SfCircularChart( + legend: Legend(isVisible: false), + tooltipBehavior: TooltipBehavior(enable: true), + series: >[ + PieSeries<_ChartData, String>( + dataSource: data, + xValueMapper: (d, _) => d.label, + yValueMapper: (d, _) => d.value, + dataLabelMapper: (d, _) => + '${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%', + dataLabelSettings: const DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.inside, + ), + radius: '100%', + ) + ], + ), + ), + + const SizedBox(width: 16), + + // Legend takes 1/3 + Expanded( + flex: 1, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Column(spacing: 4.0, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: legendItems), + ), + ), + ], + ), + ), + ); + } +} + +class _ChartData { + final String label; + final double value; + _ChartData(this.label, this.value); +} diff --git a/frontend/pweb/lib/pages/report/charts/status.dart b/frontend/pweb/lib/pages/report/charts/status.dart new file mode 100644 index 0000000..27ea708 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/status.dart @@ -0,0 +1,91 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:syncfusion_flutter_charts/charts.dart'; + +import 'package:pshared/models/payment/status.dart'; +import 'package:pshared/models/payment/operation.dart'; + + +class StatusChart extends StatelessWidget { + final List operations; + + const StatusChart({super.key, required this.operations}); + + @override + Widget build(BuildContext context) { + // 1) Compute counts + final counts = {}; + for (var op in operations) { + counts[op.status] = (counts[op.status] ?? 0) + 1; + } + final items = counts.entries + .map((e) => _ChartData(e.key, e.value.toDouble())) + .toList(); + final maxCount = items.map((e) => e.count.toInt()).fold(0, max); + + final theme = Theme.of(context); + final barColor = theme.colorScheme.secondary; + final caption = theme.textTheme.labelMedium; + + return SizedBox( + height: 200, + child: Card( + margin: const EdgeInsets.all(16), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + child: SfCartesianChart( + // ─── Axes ───────────────────────────────────────── + primaryXAxis: CategoryAxis( + labelStyle: caption, + majorGridLines: const MajorGridLines(width: 0), + ), + primaryYAxis: NumericAxis( + minimum: 0, + maximum: (maxCount + 1).toDouble(), + interval: 1, + labelStyle: caption, + majorGridLines: MajorGridLines( + color: theme.dividerColor.withAlpha(76), + width: 1, + dashArray: [4, 2], + ), + ), + + // ─── Enable tooltips ─────────────────────────────── + legend: Legend(isVisible: false), + tooltipBehavior: TooltipBehavior( + enable: true, + header: '', // omit series name in header + format: 'point.x : point.y', // e.g. "Init : 2" + ), + + // ─── Bar series with tooltip enabled ─────────────── + series: >[ + ColumnSeries<_ChartData, String>( + dataSource: items, + xValueMapper: (d, _) => d.status.localized(context), + yValueMapper: (d, _) => d.count, + color: barColor, + width: 0.6, + borderRadius: const BorderRadius.all(Radius.circular(4)), + enableTooltip: true, // <— turn on for this series + ), + ], + ), + ), + ), + ); + } +} + +class _ChartData { + final OperationStatus status; + final double count; + _ChartData(this.status, this.count); +} diff --git a/frontend/pweb/lib/pages/report/page.dart b/frontend/pweb/lib/pages/report/page.dart new file mode 100644 index 0000000..4904e3b --- /dev/null +++ b/frontend/pweb/lib/pages/report/page.dart @@ -0,0 +1,170 @@ +// operation_history_page.dart +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/operation.dart'; +import 'package:pshared/models/payment/status.dart'; + +import 'package:pweb/pages/report/charts/distribution.dart'; +import 'package:pweb/pages/report/charts/status.dart'; +import 'package:pweb/pages/report/table/filters.dart'; +import 'package:pweb/pages/report/table/widget.dart'; + + +class OperationHistoryPage extends StatefulWidget { + const OperationHistoryPage({super.key}); + + @override + State createState() => _OperationHistoryPageState(); +} + +class _OperationHistoryPageState extends State { + // Mock data + final List _allOps = [ + OperationItem( + status: OperationStatus.error, + fileName: 'cards_payout_sample_june.csv', + amount: 10, + currency: 'EUR', + toAmount: 10, + toCurrency: 'EUR', + payId: '860163800', + cardNumber: null, + name: 'John Snow', + date: DateTime(2025, 7, 14, 19, 59, 2), + comment: 'EUR visa', + ), + OperationItem( + status: OperationStatus.processing, + fileName: 'cards_payout_sample_june.csv', + amount: 10, + currency: 'EUR', + toAmount: 10, + toCurrency: 'EUR', + payId: '860163700', + cardNumber: null, + name: 'Baltasar Gelt', + date: DateTime(2025, 7, 14, 19, 59, 2), + comment: 'EUR master', + ), + OperationItem( + status: OperationStatus.error, + fileName: 'cards_payout_sample_june.csv', + amount: 10, + currency: 'EUR', + toAmount: 10, + toCurrency: 'EUR', + payId: '40000000****0077', + cardNumber: '40000000****0077', + name: 'John Snow', + date: DateTime(2025, 7, 14, 19, 23, 22), + comment: 'EUR visa', + ), + OperationItem( + status: OperationStatus.success, + fileName: null, + amount: 10, + currency: 'EUR', + toAmount: 10, + toCurrency: 'EUR', + payId: '54133300****0019', + cardNumber: '54133300****0019', + name: 'Baltasar Gelt', + date: DateTime(2025, 7, 14, 19, 23, 21), + comment: 'EUR master', + ), + OperationItem( + status: OperationStatus.success, + fileName: null, + amount: 130, + currency: 'EUR', + toAmount: 130, + toCurrency: 'EUR', + payId: '54134300****0019', + cardNumber: '54153300****0019', + name: 'Ivan Brokov', + date: DateTime(2025, 7, 15, 19, 23, 21), + comment: 'EUR master 2', + ), + ]; + DateTimeRange? _range; + final Set _statuses = {}; + late List _filtered; + + @override + void initState() { + super.initState(); + _filtered = List.from(_allOps); + } + + void _applyFilter() { + setState(() { + _filtered = _allOps.where((op) { + final okStatus = _statuses.isEmpty || _statuses.contains(op.status.localized(context)); + final okRange = _range == null || + (op.date.isAfter(_range!.start.subtract(const Duration(seconds: 1))) && + op.date.isBefore(_range!.end.add(const Duration(seconds: 1)))); + return okStatus && okRange; + }).toList(); + }); + } + + Future _pickRange() async { + final now = DateTime.now(); + final initial = _range ?? + DateTimeRange( + start: now.subtract(const Duration(days: 30)), + end: now, + ); + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: now.add(const Duration(days: 1)), + initialDateRange: initial, + ); + if (picked != null) { + setState(() => _range = picked); + } + } + + void _toggleStatus(String status) { + setState(() { + if (_statuses.contains(status)) _statuses.remove(status); + else _statuses.add(status); + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + SizedBox( + height: 200, // same height for both + child: Row( + spacing: 16, + children: [ + Expanded(child: StatusChart(operations: _allOps)), + Expanded(child: PayoutDistributionChart(operations: _allOps)), + ], + ), + ), + OperationFilters( + selectedRange: _range, + selectedStatuses: _statuses, + onPickRange: _pickRange, + onToggleStatus: _toggleStatus, + onApply: _applyFilter, + ), + OperationsTable( + operations: _filtered, + showFileNameColumn: + _allOps.any((op) => op.fileName != null), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/table/badge.dart b/frontend/pweb/lib/pages/report/table/badge.dart new file mode 100644 index 0000000..960d905 --- /dev/null +++ b/frontend/pweb/lib/pages/report/table/badge.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:badges/badges.dart' as badges; + +import 'package:pshared/models/payment/status.dart'; + + +class OperationStatusBadge extends StatelessWidget { + final OperationStatus status; + + const OperationStatusBadge({super.key, required this.status}); + + Color _badgeColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + switch (status) { + case OperationStatus.processing: + return scheme.primary; + case OperationStatus.success: + return scheme.secondary; + case OperationStatus.error: + return scheme.error; + } + } + + Color _textColor(Color background) { + // computeLuminance returns 0 for black, 1 for white + return background.computeLuminance() > 0.5 ? Colors.black : Colors.white; + } + + @override + Widget build(BuildContext context) { + final label = status.localized(context); + final bg = _badgeColor(context); + final fg = _textColor(bg); + + return badges.Badge( + badgeStyle: badges.BadgeStyle( + shape: badges.BadgeShape.square, + badgeColor: bg, + borderRadius: BorderRadius.circular(12), // fully rounded + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2 // tighter padding + ), + ), + badgeContent: Text( + label.toUpperCase(), // or keep sentence case + style: TextStyle( + color: fg, + fontSize: 11, // smaller text + fontWeight: FontWeight.w500, // medium weight + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/table/filters.dart b/frontend/pweb/lib/pages/report/table/filters.dart new file mode 100644 index 0000000..4c4583d --- /dev/null +++ b/frontend/pweb/lib/pages/report/table/filters.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:badges/badges.dart' as badges; // Make sure to add badges package in pubspec.yaml +import 'package:pshared/models/payment/status.dart'; +import 'package:pshared/utils/localization.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + +class OperationFilters extends StatelessWidget { + final DateTimeRange? selectedRange; + final Set selectedStatuses; + final VoidCallback onPickRange; + final VoidCallback onApply; + final ValueChanged onToggleStatus; + + const OperationFilters({ + super.key, + required this.selectedRange, + required this.selectedStatuses, + required this.onPickRange, + required this.onApply, + required this.onToggleStatus, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Card( + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.filters, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 12), + GestureDetector( + onTap: onPickRange, + child: Row( + children: [ + Icon(Icons.date_range_outlined, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Expanded( + child: Text( + selectedRange == null + ? l10n.selectPeriod + : '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}', + style: TextStyle( + color: selectedRange == null + ? Colors.grey + : Colors.black87, + ), + ), + ), + Icon(Icons.keyboard_arrow_down, color: Colors.grey), + ], + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + OperationStatus.success.localized(context), + OperationStatus.processing.localized(context), + OperationStatus.error.localized(context), + ].map((status) { + final isSelected = selectedStatuses.contains(status); + return GestureDetector( + onTap: () => onToggleStatus(status), + child: badges.Badge( + badgeAnimation: badges.BadgeAnimation.fade(), + badgeStyle: badges.BadgeStyle( + shape: badges.BadgeShape.square, + badgeColor: isSelected + ? Theme.of(context).primaryColor + : Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + badgeContent: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + l10n.status(status), + style: TextStyle( + color: isSelected ? Colors.white : Colors.black87, + fontSize: 14, + ), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: onApply, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(l10n.apply), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/table/row.dart b/frontend/pweb/lib/pages/report/table/row.dart new file mode 100644 index 0000000..7398610 --- /dev/null +++ b/frontend/pweb/lib/pages/report/table/row.dart @@ -0,0 +1,23 @@ +// operation_row.dart +import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/operation.dart'; +import 'package:pweb/pages/report/table/badge.dart'; + +class OperationRow { + static DataRow build(OperationItem op, BuildContext context) { + return DataRow(cells: [ + DataCell(OperationStatusBadge(status: op.status)), + DataCell(Text(op.fileName ?? '')), + DataCell(Text('${op.amount.toStringAsFixed(2)} ${op.currency}')), + DataCell(Text('${op.toAmount.toStringAsFixed(2)} ${op.toCurrency}')), + DataCell(Text(op.payId)), + DataCell(Text(op.cardNumber ?? '-')), + DataCell(Text(op.name)), + DataCell(Text( + '${TimeOfDay.fromDateTime(op.date).format(context)}\n' + '${op.date.toLocal().toIso8601String().split("T").first}', + )), + DataCell(Text(op.comment)), + ]); + } +} diff --git a/frontend/pweb/lib/pages/report/table/widget.dart b/frontend/pweb/lib/pages/report/table/widget.dart new file mode 100644 index 0000000..fb64d90 --- /dev/null +++ b/frontend/pweb/lib/pages/report/table/widget.dart @@ -0,0 +1,63 @@ +// operations_table.dart +import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/operation.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/report/table/row.dart'; + +class OperationsTable extends StatelessWidget { + final List operations; + final bool showFileNameColumn; + + const OperationsTable({ + super.key, + required this.operations, + required this.showFileNameColumn, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Expanded( + child: SingleChildScrollView( + child: DataTable( + columnSpacing: 24, + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, + ), + columns: [ + DataColumn(label: Text(l10n.statusColumn)), + DataColumn(label: Text(l10n.fileNameColumn)), + DataColumn(label: Text(l10n.amountColumn)), + DataColumn(label: Text(l10n.toAmountColumn)), + DataColumn(label: Text(l10n.payIdColumn)), + DataColumn(label: Text(l10n.cardNumberColumn)), + DataColumn(label: Text(l10n.nameColumn)), + DataColumn(label: Text(l10n.dateColumn)), + DataColumn(label: Text(l10n.commentColumn)), + ], + rows: List.generate( + operations.length, + (index) { + final op = operations[index]; + // Alternate row colors + final color = WidgetStateProperty.resolveWith((states) { + return index.isEven + ? Theme.of(context).colorScheme.surfaceContainerHighest + : null; + }); + + // Use the DataRow built by OperationRow and extract its cells + final row = OperationRow.build(op, context); + return DataRow.byIndex( + index: index, + color: color, + cells: row.cells, + ); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/avatar.dart b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart new file mode 100644 index 0000000..0f80127 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +//import 'package:provider/provider.dart'; + +import 'package:image_picker/image_picker.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class AvatarTile extends StatefulWidget { + final String? avatarUrl; + final String title; + final String description; + final String errorText; + + const AvatarTile({ + super.key, + required this.avatarUrl, + required this.title, + required this.description, + required this.errorText, + }); + + @override + State createState() => _AvatarTileState(); +} + +class _AvatarTileState extends State { + static const double _avatarSize = 96.0; + static const double _iconSize = 32.0; + static const double _titleSpacing = 4.0; + static const String _placeholderAsset = 'assets/images/avatar_placeholder.png'; + + bool _isHovering = false; + + Future _pickImage() async { + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.gallery); + if (file != null) { + debugPrint('Selected new avatar: ${file.path}'); + } + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final safeUrl = + widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null; + final theme = Theme.of(context); + + return Column( + children: [ + MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: GestureDetector( + onTap: _pickImage, + child: Stack( + alignment: Alignment.center, + children: [ + ClipOval( + child: safeUrl != null + ? Image.network( + safeUrl, + width: _avatarSize, + height: _avatarSize, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPlaceholder(), + ) + : _buildPlaceholder(), + ), + if (_isHovering) + ClipOval( + child: Container( + width: _avatarSize, + height: _avatarSize, + color: theme.colorScheme.primary.withAlpha(90), + child: Icon( + Icons.camera_alt, + color: theme.colorScheme.onSecondary, + size: _iconSize, + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: _titleSpacing), + Text( + loc.avatarHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondary, + ), + ), + ], + ); + } + + Widget _buildPlaceholder() { + return Image.asset( + _placeholderAsset, + width: _avatarSize, + height: _avatarSize, + fit: BoxFit.cover, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/account/locale.dart b/frontend/pweb/lib/pages/settings/profile/account/locale.dart new file mode 100644 index 0000000..31d5982 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/locale.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/locale.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/services/amplitude.dart'; + + +class LocalePicker extends StatelessWidget { + final String title; + + const LocalePicker({ + super.key, + required this.title, + }); + + static const double _pickerWidth = 300; + static const double _iconSize = 20; + static const double _gapMedium = 6; + static const double _gapLarge = 8; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + return Consumer( + builder: (context, localeProvider, _) { + final currentLocale = localeProvider.locale; + final options = AppLocalizations.supportedLocales; + + return SizedBox( + width: _pickerWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.language_outlined, color: theme.colorScheme.primary, size: _iconSize), + const SizedBox(width: _gapMedium), + Text(title, style: theme.textTheme.bodyMedium), + ], + ), + const SizedBox(height: _gapLarge), + DropdownButtonFormField( + initialValue: currentLocale, + items: options + .map( + (locale) => DropdownMenuItem( + value: locale, + child: Text(_localizedLocaleName(locale, loc)), + ), + ) + .toList(), + onChanged: (locale) { + if (locale != null) { + localeProvider.setLocale(locale); + AmplitudeService.localeChanged(locale); + } + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + ), + ], + ), + ); + }, + ); + } + + String _localizedLocaleName(Locale locale, AppLocalizations loc) { + switch (locale.languageCode) { + case 'en': + return 'English'; + case 'ru': + return 'Русский'; + case 'de': + return 'Deutsch'; + default: + return locale.toString(); + } + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/settings/profile/account/name.dart b/frontend/pweb/lib/pages/settings/profile/account/name.dart new file mode 100644 index 0000000..cf231e0 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/account/name.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + + +class AccountName extends StatefulWidget { + final String name; + final String title; + final String hintText; + final String errorText; + + const AccountName({ + super.key, + required this.name, + required this.title, + required this.hintText, + required this.errorText, + }); + + @override + State createState() => _AccountNameState(); +} + +class _AccountNameState extends State { + static const double _inputWidth = 200; + static const double _spacing = 8; + static const double _errorSpacing = 4; + static const double _borderWidth = 2; + + late final TextEditingController _controller; + bool _isEditing = false; + late String _originalName; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.name); + _originalName = widget.name; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _startEditing() => setState(() => _isEditing = true); + + void _cancelEditing() { + setState(() { + _controller.text = _originalName; + _isEditing = false; + }); + } + + void _saveEditing() { + setState(() { + _originalName = _controller.text; + _isEditing = false; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isEditing) + SizedBox( + width: _inputWidth, + child: TextFormField( + controller: _controller, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + autofocus: true, + decoration: InputDecoration( + hintText: widget.hintText, + isDense: true, + border: UnderlineInputBorder( + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: _borderWidth, + ), + ), + ), + ), + ) + else + Text( + _originalName, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: _spacing), + if (_isEditing) ...[ + IconButton( + icon: Icon(Icons.check, color: theme.colorScheme.primary), + onPressed: _saveEditing, + ), + IconButton( + icon: Icon(Icons.close, color: theme.colorScheme.error), + onPressed: _cancelEditing, + ), + ] else + IconButton( + icon: Icon(Icons.edit, color: theme.colorScheme.primary), + onPressed: _startEditing, + ), + ], + ), + const SizedBox(height: _errorSpacing), + if (widget.errorText.isEmpty) + Text( + widget.errorText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/profile/page.dart b/frontend/pweb/lib/pages/settings/profile/page.dart new file mode 100644 index 0000000..ce850f4 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/profile/page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/settings/profile/account/avatar.dart'; +import 'package:pweb/pages/settings/profile/account/locale.dart'; +import 'package:pweb/pages/settings/profile/account/name.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ProfileSettingsPage extends StatelessWidget { + const ProfileSettingsPage({super.key}); + + static const _cardPadding = EdgeInsets.symmetric(vertical: 32, horizontal: 16); + static const _cardRadius = 16.0; + static const _itemSpacing = 12.0; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(_cardRadius), + clipBehavior: Clip.antiAlias, + color: theme.colorScheme.onSecondary, + child: Padding( + padding: _cardPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: _itemSpacing, + children: [ + AvatarTile( + avatarUrl: 'https://avatars.githubusercontent.com/u/65651201', + title: loc.avatar, + description: loc.avatarHint, + errorText: loc.avatarUpdateError, + ), + AccountName( + name: 'User Name', + title: loc.accountName, + hintText: loc.accountNameHint, + errorText: loc.accountNameUpdateError, + ), + LocalePicker( + title: loc.language, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/settings/widgets/base.dart b/frontend/pweb/lib/pages/settings/widgets/base.dart new file mode 100644 index 0000000..31a6a96 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/widgets/base.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/utils/error/snackbar.dart'; + +enum _EditState { view, edit, saving } + +/// Базовый класс, управляющий состояниями (view/edit/saving), +/// показом snackbar ошибок и успешного сохранения. +abstract class BaseEditTile extends AbstractSettingsTile { + const BaseEditTile({ + super.key, + required this.icon, + required this.title, + required this.valueGetter, + required this.valueSetter, + required this.errorSituation, + }); + + final IconData icon; + final String title; + final ValueGetter valueGetter; + final Future Function(T) valueSetter; + final String errorSituation; + + /// Рисует в режиме просмотра (read-only). + Widget buildView(BuildContext context, T? value); + + /// Рисует UI редактора. + /// Если [useDialogEditor]==true, его обернут в диалог. + Widget buildEditor( + BuildContext context, + T? initial, + void Function(T) onSave, + VoidCallback onCancel, + bool isSaving, + ); + + /// true → показывать редактор в диалоге, false → inline под заголовком. + bool get useDialogEditor => false; + + @override + Widget build(BuildContext context) => _BaseEditTileBody(delegate: this); +} + +class _BaseEditTileBody extends StatefulWidget { + const _BaseEditTileBody({required this.delegate}); + final BaseEditTile delegate; + @override + State<_BaseEditTileBody> createState() => _BaseEditTileBodyState(); +} + +class _BaseEditTileBodyState extends State<_BaseEditTileBody> { + _EditState _state = _EditState.view; + bool get _isSaving => _state == _EditState.saving; + + Future _performSave(T newValue) async { + final current = widget.delegate.valueGetter(); + if (newValue == current) { + setState(() => _state = _EditState.view); + return; + } + setState(() => _state = _EditState.saving); + final sms = ScaffoldMessenger.of(context); + final locs = AppLocalizations.of(context)!; + try { + await widget.delegate.valueSetter(newValue); + sms.showSnackBar(SnackBar( + content: Text(locs.settingsSuccessfullyUpdated), + duration: const Duration(milliseconds: 1200), + )); + } catch (e) { + notifyUserOfErrorX( + scaffoldMessenger: sms, + errorSituation: widget.delegate.errorSituation, + appLocalizations: locs, + exception: e, + ); + } finally { + if (mounted) setState(() => _state = _EditState.view); + } + } + + Future _openDialogEditor() async { + final initial = widget.delegate.valueGetter(); + final T? result = await showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) { + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: Padding( + padding: const EdgeInsets.all(16), + child: widget.delegate.buildEditor( + ctx, + initial, + (v) => Navigator.of(ctx).pop(v), + () => Navigator.of(ctx).pop(), + _isSaving, + ), + ), + ); + }, + ); + if (result != null) await _performSave(result); + } + + @override + Widget build(BuildContext context) { + final delegate = widget.delegate; + final current = delegate.valueGetter(); + + // Диалоговый режим + if (delegate.useDialogEditor) { + return SettingsTile.navigation( + leading: Icon(delegate.icon), + title: Text(delegate.title), + value: delegate.buildView(context, current), + onPressed: (_) => _openDialogEditor(), + ); + } + + // Inline-режим (под заголовком будет редактор прямо в tile) + return SettingsTile.navigation( + leading: Icon(delegate.icon), + title: Text(delegate.title), + value: _state == _EditState.view + ? delegate.buildView(context, current) + : delegate.buildEditor( + context, + current, + _performSave, + () => setState(() => _state = _EditState.view), + _isSaving, + ), + onPressed: (_) { + if (_state == _EditState.view) setState(() => _state = _EditState.edit); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/widgets/image.dart b/frontend/pweb/lib/pages/settings/widgets/image.dart new file mode 100644 index 0000000..ed076bb --- /dev/null +++ b/frontend/pweb/lib/pages/settings/widgets/image.dart @@ -0,0 +1,112 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; + +import 'package:image_picker/image_picker.dart'; + +import 'package:pweb/utils/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ImageTile extends AbstractSettingsTile { + final String? imageUrl; + final double? maxWidth; + final double? maxHeight; + final String? imageUpdateError; + final Future Function(XFile?) onUpdate; + final String? title; + final String? description; + final Widget? imagePreview; + final double previewWidth; + final double previewHeight; + + const ImageTile({ + super.key, + required this.imageUrl, + this.maxWidth, + this.maxHeight, + this.imageUpdateError, + required this.onUpdate, + this.title, + this.description, + this.imagePreview, + this.previewHeight = 40.0, + this.previewWidth = 40.0, + }); + + Future _pickImage(BuildContext context) async { + final picker = ImagePicker(); + final locs = AppLocalizations.of(context)!; + final sm = ScaffoldMessenger.of(context); + final picked = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: maxWidth, + maxHeight: maxHeight, + ); + if (picked == null) return; + + try { + await onUpdate(picked); + if (imageUrl != null) { + CachedNetworkImage.evictFromCache(imageUrl!); + } + } catch (e) { + notifyUserOfErrorX( + scaffoldMessenger: sm, + errorSituation: imageUpdateError ?? locs.settingsImageUpdateError, + exception: e, + appLocalizations: locs, + ); + } + } + + @override + Widget build(BuildContext context) => SettingsTile.navigation( + leading: imagePreview ?? + ClipRRect( + borderRadius: BorderRadius.circular(0.1 * (previewWidth < previewHeight ? previewWidth : previewHeight)), + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl!, + width: previewWidth, + height: previewHeight, + fit: BoxFit.cover, + progressIndicatorBuilder: (ctx, url, downloadProgress) { + // compute 10% of the smaller image dimension, but no more than 40px + final baseSize = min(previewWidth, previewHeight) * 0.1; + final indicatorSize = baseSize.clamp(0.0, 40.0); + + return Center( + child: SizedBox( + width: indicatorSize, + height: indicatorSize, + child: CircularProgressIndicator( + value: downloadProgress.progress, // from 0.0 to 1.0 + strokeWidth: max(indicatorSize * 0.1, 2.0), // 10% of size, but at least 2px so it’s visible + ), + ), + ); + }, + errorWidget: (ctx, url, err) => const Icon(Icons.error), + ) + : Container( + width: previewWidth, + height: previewHeight, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_not_supported, + size: previewWidth * 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + title: Text(title ?? AppLocalizations.of(context)!.settingsImageTitle), + description: Text(description ?? AppLocalizations.of(context)!.settingsImageHint), + onPressed: (_) => _pickImage(context), + ); +} diff --git a/frontend/pweb/lib/pages/settings/widgets/pick.dart b/frontend/pweb/lib/pages/settings/widgets/pick.dart new file mode 100644 index 0000000..7bf7933 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/widgets/pick.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/pages/settings/widgets/base.dart'; + + +const _kSearchThreshold = 12; + +class SelectValueTile extends BaseEditTile { + final double? maxEditorHeight; + final double? maxEditorWidth; + + const SelectValueTile({ + super.key, + required super.icon, + required super.title, + required super.valueGetter, + required super.valueSetter, + required super.errorSituation, + required this.options, + required this.labelBuilder, + this.filterOptions, + this.maxEditorHeight, + this.maxEditorWidth, + }); + + final List options; + final String Function(T) labelBuilder; + final List Function(String)? filterOptions; + + @override + bool get useDialogEditor => true; + + @override + Widget buildView(BuildContext context, T? value) { + return Text( + value == null ? AppLocalizations.of(context)!.notSet : labelBuilder(value), + ); + } + + @override + Widget buildEditor( + BuildContext context, + T? initial, + void Function(T) onSave, + VoidCallback onCancel, + bool isSaving, + ) { + // local state for the current search query + String searchText = ''; + + return StatefulBuilder( + builder: (context, setState) { + // decide which list to show + final displayedOptions = (options.length > _kSearchThreshold && filterOptions != null && searchText.isNotEmpty) + ? filterOptions!(searchText) + : options; + + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (options.length > _kSearchThreshold && filterOptions != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: TextField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.search, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + // update the local searchText and rebuild the list + setState(() { + searchText = value; + }); + }, + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + children: displayedOptions.map((o) => RadioListTile( + value: o, + groupValue: initial, + title: Text(labelBuilder(o)), + onChanged: isSaving ? null : (v) { if (v != null) onSave(v); }, + )).toList(), + ), + ), + const Divider(), + TextButton( + onPressed: onCancel, + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ); + + // if the caller passed a max size, enforce it: + if (maxEditorHeight != null || maxEditorWidth != null) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxEditorHeight ?? double.infinity, + maxWidth: maxEditorWidth ?? double.infinity, + ), + child: content, + ); + } + + return content; + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/settings/widgets/text.dart b/frontend/pweb/lib/pages/settings/widgets/text.dart new file mode 100644 index 0000000..a297ca9 --- /dev/null +++ b/frontend/pweb/lib/pages/settings/widgets/text.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/settings/widgets/base.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class TextEditTile extends BaseEditTile { + const TextEditTile({ + super.key, + required super.icon, + required super.title, + required super.valueGetter, + required super.valueSetter, + required super.errorSituation, + required this.hintText, + }); + + final String hintText; + + @override + Widget buildView(BuildContext context, String? value) { + final locs = AppLocalizations.of(context)!; + final display = (value ?? '').isEmpty ? locs.notSet : value!; + return Text( + display, + semanticsLabel: (value ?? '').isEmpty ? locs.notSet : '$title: $display', + ); + } + + @override + Widget buildEditor( + BuildContext context, + String? initial, + void Function(String) onSave, + VoidCallback onCancel, + bool isSaving, + ) { + final controller = TextEditingController(text: initial ?? ''); + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: hintText, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + ), + onSubmitted: (_) => onSave(controller.text.trim()), + ), + ), + const SizedBox(width: 8.0), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + key: const ValueKey('actions'), + children: [ + Tooltip( + message: AppLocalizations.of(context)!.ok, + child: IconButton( + icon: const Icon(Icons.check), + onPressed: () => onSave(controller.text.trim()), + visualDensity: VisualDensity.compact, + ), + ), + Tooltip( + message: AppLocalizations.of(context)!.cancel, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: onCancel, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/signup/buttons.dart b/frontend/pweb/lib/pages/signup/buttons.dart new file mode 100644 index 0000000..6776687 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/buttons.dart @@ -0,0 +1,31 @@ + +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignUpButtonsRow extends StatelessWidget { + final VoidCallback onLogin; + final VoidCallback signUp; + final bool isEnabled; + + const SignUpButtonsRow({ + super.key, + required this.onLogin, + required this.signUp, + required this.isEnabled, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: isEnabled ? signUp : null, + child: Text(AppLocalizations.of(context)!.signup), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/buttons.dart b/frontend/pweb/lib/pages/signup/form/buttons.dart new file mode 100644 index 0000000..754c77f --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/buttons.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + + +class SignUpBackButton extends StatelessWidget { + const SignUpBackButton({super.key}); + + @override + Widget build(BuildContext context) => Row( + children: [ + IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.arrow_back), + ), + ], + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/content.dart b/frontend/pweb/lib/pages/signup/form/content.dart new file mode 100644 index 0000000..93cce12 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/content.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/signup/buttons.dart'; +import 'package:pweb/pages/signup/form/controllers.dart'; +import 'package:pweb/pages/signup/form/feilds.dart'; +import 'package:pweb/widgets/constrained_form.dart'; + + +class SignUpFormContent extends StatelessWidget { + final GlobalKey formKey; + final SignUpFormControllers controllers; + final bool autoValidateMode; + final VoidCallback onSignUp; + final VoidCallback onLogin; + + const SignUpFormContent({ + required this.formKey, + required this.controllers, + required this.autoValidateMode, + required this.onSignUp, + required this.onLogin, + super.key, + }); + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 700), + child: Card( + child: SingleChildScrollView( + child: Column( + children: [ + Row( + children: [ + IconButton( + onPressed: Navigator.of(context).pop, + icon: Icon(Icons.arrow_back), + ), + ], + ), + ConstrainedForm( + formKey: formKey, + autovalidateMode: autoValidateMode + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + children: [ + SignUpFormFields(controllers: controllers), + SignUpButtonsRow( + onLogin: onLogin, + signUp: onSignUp, + isEnabled: true, + ), + ], + ), + ], + ), + ), + ), + ), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/controllers.dart b/frontend/pweb/lib/pages/signup/form/controllers.dart new file mode 100644 index 0000000..da78c9c --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/controllers.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + + +class SignUpFormControllers { + final TextEditingController companyName = TextEditingController(); + final TextEditingController description = TextEditingController(); + final TextEditingController firstName = TextEditingController(); + final TextEditingController lastName = TextEditingController(); + final TextEditingController email = TextEditingController(); + final TextEditingController password = TextEditingController(); + final TextEditingController passwordConfirm = TextEditingController(); + + void dispose() { + companyName.dispose(); + description.dispose(); + firstName.dispose(); + lastName.dispose(); + email.dispose(); + password.dispose(); + passwordConfirm.dispose(); + } +} diff --git a/frontend/pweb/lib/pages/signup/form/description.dart b/frontend/pweb/lib/pages/signup/form/description.dart new file mode 100644 index 0000000..c672792 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/description.dart @@ -0,0 +1,24 @@ + +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class DescriptionField extends StatelessWidget { + final TextEditingController controller; + + const DescriptionField({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: '${AppLocalizations.of(context)!.companyDescription} (${AppLocalizations.of(context)!.optional})', + hintText: AppLocalizations.of(context)!.companyDescriptionHint, + ), + maxLines: 3, + maxLength: 500, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/email.dart b/frontend/pweb/lib/pages/signup/form/email.dart new file mode 100644 index 0000000..a8fddb3 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/email.dart @@ -0,0 +1,32 @@ + +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +//TODO check with /widgets/username.dart + +class EmailField extends StatelessWidget { + final TextEditingController controller; + + const EmailField({super.key, required this.controller}); + + static final _emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.username, + hintText: AppLocalizations.of(context)!.usernameHint, + ), + validator: (value) { + if (value == null || !_emailRegex.hasMatch(value)) { + return AppLocalizations.of(context)!.usernameErrorInvalid; + } + return null; + }, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/feilds.dart b/frontend/pweb/lib/pages/signup/form/feilds.dart new file mode 100644 index 0000000..d62ce67 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/feilds.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/signup/form/controllers.dart'; +import 'package:pweb/pages/signup/form/description.dart'; +import 'package:pweb/pages/signup/form/email.dart'; +import 'package:pweb/pages/signup/header.dart'; +import 'package:pweb/widgets/password/hint/short.dart'; +import 'package:pweb/widgets/password/password.dart'; +import 'package:pweb/widgets/password/verify.dart'; +import 'package:pweb/widgets/text_field.dart'; +import 'package:pweb/widgets/vspacer.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignUpFormFields extends StatelessWidget { + final SignUpFormControllers controllers; + + const SignUpFormFields({ + required this.controllers, + super.key, + }); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SignUpHeader(), + const VSpacer(), + NotEmptyTextFormField( + controller: controllers.companyName, + labelText: AppLocalizations.of(context)!.companyName, + readOnly: false, + error: AppLocalizations.of(context)!.companynameRequired, + ), + const VSpacer(), + DescriptionField( + controller: controllers.description, + ), + const VSpacer(), + NotEmptyTextFormField( + controller: controllers.firstName, + labelText: AppLocalizations.of(context)!.lastName, + readOnly: false, + error: AppLocalizations.of(context)!.enterLastName, + ), + const VSpacer(), + NotEmptyTextFormField( + controller: controllers.lastName, + labelText: AppLocalizations.of(context)!.firstName, + readOnly: false, + error: AppLocalizations.of(context)!.enterFirstName, + ), + const VSpacer(), + EmailField(controller: controllers.email), + const VSpacer(), + defaulRulesPasswordField( + context, + controller: controllers.password, + validationRuleBuilder: (rules, value) => + shortValidation(context, rules, value), + ), + const VSpacer(multiplier: 2.0), + VerifyPasswordField( + controller: controllers.passwordConfirm, + externalPasswordController: controllers.password, + ), + const VSpacer(multiplier: 2.0), + ], + ); +} diff --git a/frontend/pweb/lib/pages/signup/form/form.dart b/frontend/pweb/lib/pages/signup/form/form.dart new file mode 100644 index 0000000..16627e9 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/form.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/signup/form/state.dart'; + + +class SignUpForm extends StatefulWidget { + const SignUpForm({super.key}); + + @override + State createState() => SignUpFormState(); +} diff --git a/frontend/pweb/lib/pages/signup/form/state.dart b/frontend/pweb/lib/pages/signup/form/state.dart new file mode 100644 index 0000000..a25ec84 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/form/state.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/pfe/provider.dart'; + +import 'package:pweb/app/router/pages.dart'; +import 'package:pweb/pages/signup/form/content.dart'; +import 'package:pweb/pages/signup/form/controllers.dart'; +import 'package:pweb/pages/signup/form/form.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignUpFormState extends State { + late final SignUpFormControllers controllers; + final _formKey = GlobalKey(); + bool _autoValidateMode = false; + + @override + void initState() { + super.initState(); + controllers = SignUpFormControllers(); + } + + Future signUp( + BuildContext context, + VoidCallback onSignUp, + void Function(Object e) onError, + ) async { + final pfeProvider = Provider.of(context, listen: false); + + setState(() { + _autoValidateMode = true; + }); + + if (!(_formKey.currentState?.validate() ?? false)) { + return null; + } + + try { + // final account = await pfeProvider.signUp( + // companyName: controllers.companyName.text.trim(), + // description: controllers.description.text.trim().isEmpty + // ? null + // : controllers.description.text.trim(), + // firstName: controllers.firstName.text.trim(), + // lastName: controllers.lastName.text.trim(), + // email: controllers.email.text.trim(), + // password: controllers.password.text, + // ); + onSignUp(); + return 'ok'; + } catch (e) { + onError(pfeProvider.error ?? e); + } + return null; + } + + void handleSignUp() => signUp( + context, + () { + context.goNamed( + Pages.sfactor.name, + queryParameters: {'from': 'signup'}, + ); + }, + (e) => postNotifyUserOfErrorX( + context: context, + errorSituation: AppLocalizations.of(context)!.errorSignUp, + exception: e, + ), + ); + + void handleLogin() => navigate(context, Pages.login); + + @override + void dispose() { + controllers.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SignUpFormContent( + formKey: _formKey, + controllers: controllers, + autoValidateMode: _autoValidateMode, + onSignUp: handleSignUp, + onLogin: handleLogin, + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/header.dart b/frontend/pweb/lib/pages/signup/header.dart new file mode 100644 index 0000000..2c0198b --- /dev/null +++ b/frontend/pweb/lib/pages/signup/header.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:pweb/config/constants.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/widgets/hspacer.dart'; +import 'package:pweb/widgets/logo.dart'; + + +class SignUpHeader extends StatelessWidget { + const SignUpHeader({super.key}); + + @override + Widget build(BuildContext context) => Row( + children: [ + const ServiceLogo(size: 36), + const HSpacer(multiplier: 0.75), + Text( + '${AppConfig.appName} ${AppLocalizations.of(context)!.signup}', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ], + ); +} diff --git a/frontend/pweb/lib/pages/signup/page.dart b/frontend/pweb/lib/pages/signup/page.dart new file mode 100644 index 0000000..752b78c --- /dev/null +++ b/frontend/pweb/lib/pages/signup/page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pweb/pages/login/app_bar.dart'; +import 'package:pweb/pages/signup/form/form.dart'; +import 'package:pweb/pages/with_footer.dart'; + + +class SignUpPage extends StatelessWidget { + const SignUpPage({super.key}); + + @override + Widget build(BuildContext context) => PageWithFooter( + appBar: const LoginAppBar(), + child: SignUpForm(), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/with_footer.dart b/frontend/pweb/lib/pages/with_footer.dart new file mode 100644 index 0000000..f3f2ae8 --- /dev/null +++ b/frontend/pweb/lib/pages/with_footer.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/footer/widget.dart'; + + +class PageWithFooter extends StatelessWidget { + final PreferredSizeWidget? appBar; + final Widget child; + + const PageWithFooter({super.key, required this.child, this.appBar}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: appBar, + body: Column( + children: [ + Expanded(child: child), + FooterWidget(), + ], + ), + ); +} diff --git a/frontend/pweb/lib/providers/balance.dart b/frontend/pweb/lib/providers/balance.dart new file mode 100644 index 0000000..95c9c76 --- /dev/null +++ b/frontend/pweb/lib/providers/balance.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/services/balance.dart'; + + +class BalanceProvider with ChangeNotifier { + final BalanceService _service; + + BalanceProvider(this._service); + + double? _balance; + String? _walletName; + String? _walletId; + bool _isHidden = true; + + double? get balance => _balance; + String? get walletName => _walletName; + String? get walletId => _walletId; + bool get isHidden => _isHidden; + + Future loadData() async { + _balance = await _service.getBalance(); + _walletName = await _service.getWalletName(); + _walletId = await _service.getWalletId(); + notifyListeners(); + } + + void toggleVisibility() { + _isHidden = !_isHidden; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/providers/carousel.dart b/frontend/pweb/lib/providers/carousel.dart new file mode 100644 index 0000000..8a7bfcd --- /dev/null +++ b/frontend/pweb/lib/providers/carousel.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + + +class CarouselIndexProvider extends ChangeNotifier { + int _currentIndex = 0; + + int get currentIndex => _currentIndex; + + void updateIndex(int index) { + if (_currentIndex != index) { + _currentIndex = index; + notifyListeners(); + } + } +} diff --git a/frontend/pweb/lib/providers/mock_payment.dart b/frontend/pweb/lib/providers/mock_payment.dart new file mode 100644 index 0000000..6c8ed24 --- /dev/null +++ b/frontend/pweb/lib/providers/mock_payment.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + + +class MockPaymentProvider with ChangeNotifier { + double _amount = 10.0; + bool _payerCoversFee = true; + + double get amount => _amount; + bool get payerCoversFee => _payerCoversFee; + + double get fee => _amount * 0.05; + double get total => payerCoversFee ? (_amount + fee) : _amount; + double get recipientGets => payerCoversFee ? _amount : (_amount - fee); + + void setAmount(double value) { + _amount = value; + notifyListeners(); + } + + void setPayerCoversFee(bool value) { + _payerCoversFee = value; + notifyListeners(); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/providers/page_selector.dart b/frontend/pweb/lib/providers/page_selector.dart new file mode 100644 index 0000000..b1b1874 --- /dev/null +++ b/frontend/pweb/lib/providers/page_selector.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/recipient.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/providers/wallets.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/services/amplitude.dart'; +import 'package:pweb/providers/recipient.dart'; + + +class PageSelectorProvider extends ChangeNotifier { + PayoutDestination _selected = PayoutDestination.dashboard; + PaymentType? _type; + bool _cameFromRecipientList = false; + + final RecipientProvider? recipientProvider; + final WalletsProvider? walletsProvider; + + PayoutDestination get selected => _selected; + PaymentType? get type => _type; + + PageSelectorProvider({this.recipientProvider, this.walletsProvider}); + + void selectPage(PayoutDestination dest) { + _selected = dest; + notifyListeners(); + } + + void selectRecipient(Recipient? recipient, {bool fromList = false}) { + if (recipientProvider != null) { + recipientProvider!.selectRecipient(recipient); + _cameFromRecipientList = fromList; + _selected = PayoutDestination.payment; + notifyListeners(); + } else { + debugPrint("RecipientProvider is null — cannot select recipient"); + } + } + + void editRecipient(Recipient? recipient, {bool fromList = false}) { + if (recipientProvider != null) { + recipientProvider!.selectRecipient(recipient); + _cameFromRecipientList = fromList; + _selected = PayoutDestination.addrecipient; + notifyListeners(); + } else { + debugPrint("RecipientProvider is null — cannot select recipient"); + } + } + + void goToAddRecipient() { + if (recipientProvider != null) { + AmplitudeService.recipientAddStarted(); + recipientProvider!.selectRecipient(null); + _selected = PayoutDestination.addrecipient; + _cameFromRecipientList = false; + notifyListeners(); + } else { + debugPrint("RecipientProvider is null — cannot go to add recipient"); + } + } + + void startPaymentWithoutRecipient(PaymentType type) { + if (recipientProvider != null) { + recipientProvider!.selectRecipient(null); + } + _type = type; + _cameFromRecipientList = false; + _selected = PayoutDestination.payment; + notifyListeners(); + } + + void goBackFromPayment() { + _selected = _cameFromRecipientList + ? PayoutDestination.recipients + : PayoutDestination.dashboard; + _type = null; + notifyListeners(); + } + + void goBackFromWalletEdit() { + selectPage(PayoutDestination.methods); + } + + void selectWallet(Wallet wallet) { + if (walletsProvider != null) { + walletsProvider!.selectWallet(wallet); + _selected = PayoutDestination.editwallet; + notifyListeners(); + } else { + debugPrint("RecipientProvider is null — cannot select wallet"); + } + } + + Recipient? get selectedRecipient => recipientProvider?.selectedRecipient; + Wallet? get selectedWallet => walletsProvider?.selectedWallet; +} \ No newline at end of file diff --git a/frontend/pweb/lib/providers/payment_methods.dart b/frontend/pweb/lib/providers/payment_methods.dart new file mode 100644 index 0000000..22dc3d7 --- /dev/null +++ b/frontend/pweb/lib/providers/payment_methods.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/type.dart'; + +import 'package:pweb/services/payments/payment_methods.dart'; + + +class PaymentMethodsProvider extends ChangeNotifier { + final PaymentMethodsService service; + + List _methods = []; + PaymentMethod? _selectedMethod; + bool _isLoading = false; + String? _error; + + PaymentMethodsProvider({required this.service}); + + List get methods => _methods; + PaymentMethod? get selectedMethod => _selectedMethod; + bool get isLoading => _isLoading; + String? get error => _error; + + Future loadMethods() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _methods = await service.fetchMethods(); + _selectedMethod = _methods.firstWhere((m) => m.isMain, orElse: () => _methods.first); + } catch (e) { + _error = e.toString(); + } + + _isLoading = false; + notifyListeners(); + } + + void selectMethod(PaymentMethod method) { + _selectedMethod = method; + notifyListeners(); + } + + void deleteMethod(PaymentMethod method) { + _methods.remove(method); + notifyListeners(); + } + + void reorderMethods(int oldIndex, int newIndex) { + if (newIndex > oldIndex) newIndex--; + final item = _methods.removeAt(oldIndex); + _methods.insert(newIndex, item); + notifyListeners(); + } + + void toggleEnabled(PaymentMethod method, bool value) { + method.isEnabled = value; + notifyListeners(); + } + + void makeMain(PaymentMethod method) { + for (final m in _methods) m.isMain = false; + method.isMain = true; + selectMethod(method); + } +} diff --git a/frontend/pweb/lib/providers/recipient.dart b/frontend/pweb/lib/providers/recipient.dart new file mode 100644 index 0000000..c5d72d2 --- /dev/null +++ b/frontend/pweb/lib/providers/recipient.dart @@ -0,0 +1,80 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/recipient/filter.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/models/recipient/status.dart'; + +import 'package:pweb/services/recipient/recipient.dart'; + + +class RecipientProvider extends ChangeNotifier { + final RecipientService _service; + + RecipientProvider(this._service); + + List _recipients = []; + bool _isLoading = false; + String? _error; + RecipientFilter _selectedFilter = RecipientFilter.all; + String _query = ''; + + Recipient? _selectedRecipient; + + List get recipients => _recipients; + bool get isLoading => _isLoading; + String? get error => _error; + RecipientFilter get selectedFilter => _selectedFilter; + String get query => _query; + Recipient? get selectedRecipient => _selectedRecipient; + + List get filteredRecipients { + List filtered = _recipients.where((r) { + switch (_selectedFilter) { + case RecipientFilter.ready: + return r.status == RecipientStatus.ready; + case RecipientFilter.registered: + return r.status == RecipientStatus.registered; + case RecipientFilter.notRegistered: + return r.status == RecipientStatus.notRegistered; + case RecipientFilter.all: + return true; + } + }).toList(); + + if (_query.isNotEmpty) { + filtered = filtered.where((r) => r.matchesQuery(_query)).toList(); + } + + return filtered; + } + + Future loadRecipients() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _recipients = await _service.fetchRecipients(); + } catch (e) { + _error = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void setFilter(RecipientFilter filter) { + _selectedFilter = filter; + notifyListeners(); + } + + void setQuery(String query) { + _query = query.trim().toLowerCase(); + notifyListeners(); + } + + void selectRecipient(Recipient? recipient) { + _selectedRecipient = recipient; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/providers/template.dart b/frontend/pweb/lib/providers/template.dart new file mode 100644 index 0000000..4f5c4c2 --- /dev/null +++ b/frontend/pweb/lib/providers/template.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + + +class FutureProviderTemplate extends ChangeNotifier { + FutureProviderTemplate({required this.loader}); + + final Future Function() loader; + + T? _data; + bool _isLoading = false; + String? _error; + + T? get data => _data; + bool get isLoading => _isLoading; + String? get error => _error; + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _data = await loader(); + } catch (e) { + _error = e.toString(); + } + + _isLoading = false; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart new file mode 100644 index 0000000..c5a74e4 --- /dev/null +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/services/auth.dart'; + + +class TwoFactorProvider extends ChangeNotifier { + final AuthenticationService _authService; + + TwoFactorProvider(this._authService); + + bool _isSubmitting = false; + bool _hasError = false; + bool _verificationSuccess = false; + + bool get isSubmitting => _isSubmitting; + bool get hasError => _hasError; + bool get verificationSuccess => _verificationSuccess; + + + Future submitCode(String code) async { + _isSubmitting = true; + _hasError = false; + _verificationSuccess = false; + notifyListeners(); + + try { + final success = await _authService.verifyTwoFactorCode(code); + if (success) { + _verificationSuccess = true; + } + } catch (e) { + _hasError = true; + } finally { + _isSubmitting = false; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/providers/upload_history.dart b/frontend/pweb/lib/providers/upload_history.dart new file mode 100644 index 0000000..02a8325 --- /dev/null +++ b/frontend/pweb/lib/providers/upload_history.dart @@ -0,0 +1,10 @@ +import 'package:pshared/models/payment/upload_history_item.dart'; + +import 'package:pweb/providers/template.dart'; +import 'package:pweb/services/payments/upload_history.dart'; + + +class UploadHistoryProvider extends FutureProviderTemplate> { + UploadHistoryProvider({required UploadHistoryService service}) + : super(loader: service.fetchHistory); +} \ No newline at end of file diff --git a/frontend/pweb/lib/providers/wallets.dart b/frontend/pweb/lib/providers/wallets.dart new file mode 100644 index 0000000..f7c16f4 --- /dev/null +++ b/frontend/pweb/lib/providers/wallets.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/wallet.dart'; +import 'package:pweb/services/wallets.dart'; + + +class WalletsProvider with ChangeNotifier { + final WalletsService _service; + + WalletsProvider(this._service); + + List? _wallets; + bool _isLoading = false; + String? _error; + Wallet? _selectedWallet; + bool _isHidden = true; + + List? get wallets => _wallets; + bool get isLoading => _isLoading; + String? get error => _error; + Wallet? get selectedWallet => _selectedWallet; + bool get isHidden => _isHidden; + + + + + void selectWallet(Wallet wallet) { + _selectedWallet = wallet; + notifyListeners(); + } + + Future loadData() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _wallets = await _service.getWallets(); + } catch (e) { + _error = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future getWalletById(String walletId) async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final wallet = await _service.getWallet(walletId); + return wallet; + } catch (e) { + _error = e.toString(); + return null; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateName(String walletRef, String newName) { + final index = _wallets?.indexWhere((w) => w.id == walletRef); + if (index != null && index >= 0) { + _wallets![index] = _wallets![index].copyWith(name: newName); + notifyListeners(); + } + } + + + void updateBalance(String walletRef, double newBalance) { + final index = _wallets?.indexWhere((w) => w.id == walletRef); + if (index != null && index >= 0) { + _wallets![index] = _wallets![index].copyWith(balance: newBalance); + notifyListeners(); + } + } + + Future updateWallet(Wallet wallet) async { + try { + await _service.updateWallet(); + final index = _wallets?.indexWhere((w) => w.id == wallet.id); + if (index != null && index >= 0) { + _wallets![index] = wallet; + notifyListeners(); + } + } catch (e) { + _error = e.toString(); + notifyListeners(); + } + } + + + Future addWallet(Wallet wallet) async { + try { + final newWallet = await _service.createWallet(); // Pass the wallet parameter + _wallets = [...?_wallets, ]; // Add the new wallet + notifyListeners(); + } catch (e) { + _error = e.toString(); + notifyListeners(); + } + } + + Future deleteWallet(String walletId) async { + try { + await _service.deleteWallet(); // Pass the walletId parameter + _wallets?.removeWhere((w) => w.id == walletId); + notifyListeners(); + } catch (e) { + _error = e.toString(); + notifyListeners(); + } + } + + void toggleVisibility(String walletId) { + final index = _wallets?.indexWhere((w) => w.id == walletId); + if (index != null && index >= 0) { + final wallet = _wallets![index]; + _wallets![index] = wallet.copyWith(isHidden: !wallet.isHidden); + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/services/amplitude.dart b/frontend/pweb/lib/services/amplitude.dart new file mode 100644 index 0000000..c1384f4 --- /dev/null +++ b/frontend/pweb/lib/services/amplitude.dart @@ -0,0 +1,210 @@ +import 'package:amplitude_flutter/amplitude.dart'; +import 'package:amplitude_flutter/configuration.dart'; +import 'package:amplitude_flutter/constants.dart' as amp; +import 'package:amplitude_flutter/events/base_event.dart'; +import 'package:flutter/widgets.dart'; +import 'package:pshared/models/account/account.dart'; +import 'package:pshared/models/payment/type.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +class AmplitudeService { + static late Amplitude _analytics; + + static Amplitude _amp() => _analytics; + + static Future initialize() async { + _analytics = Amplitude(Configuration( + apiKey: '12345', //TODO define through App Contants + serverZone: amp.ServerZone.eu, //TODO define through App Contants + )); + await _analytics.isBuilt; + } + + static Future identify(Account account) async => + _amp().setUserId(account.id); + + + static Future login(Account account) async => + _logEvent( + 'login', + userProperties: { + // 'email': account.email, TODO Add email into account + 'locale': account.locale, + }, + ); + + static Future logout() async => _logEvent("logout"); + + static Future pageOpened(PayoutDestination page, {String? path, String? uiSource}) async { + return _logEvent("pageOpened", eventProperties: { + "page": page, + if (path != null) "path": path, + if (uiSource != null) "uiSource": uiSource, + }); + } + + //TODO Add when registration is ready. User properties {user_id, registration_date, has_wallet (true/false), wallet_balance (should concider loggin it as: 0 / <100 / 100–500 / 500+), preferred_method (Wallet/Card/Bank/IBAN), total_transactions, total_amount, last_payout_date, last_login_date , marketing_source} + + // static Future registrationStarted(String method, String country) async => + // _logEvent("registrationStarted", eventProperties: {"method": method, "country": country}); + + // static Future registrationCompleted(String method, String country) async => + // _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country}); + + static Future pageNotFound(String url) async => + _logEvent("pageNotFound", eventProperties: {"url": url}); + + static Future localeChanged(Locale locale) async => + _logEvent("localeChanged", eventProperties: {"locale": locale.toString()}); + + static Future localeMatched(String locale, bool haveRequested) async => //DO we need it? + _logEvent("localeMatched", eventProperties: { + "locale": locale, + "have_requested_locale": haveRequested + }); + + static Future recipientAddStarted() async => + _logEvent("recipientAddStarted"); + + static Future recipientAddCompleted( + RecipientType type, + RecipientStatus status, + Set methods, + ) async { + _logEvent( + "recipientAddCompleted", + eventProperties: { + "methods": methods.map((m) => m.name).toList(), + "type": type.name, + "status": status.name, + }, + ); + } + + static Future _paymentEvent( + String evt, + double amount, + double fee, + bool payerCoversFee, + PaymentType source, + PaymentType recpientPaymentMethod, { + String? message, + String? errorType, + Map? extraProps, + }) async { + final props = { + "amount": amount, + "fee": fee, + "feeCoveredBy": payerCoversFee ? 'payer' : 'recipient', + "source": source, + "recipient_method": recpientPaymentMethod, + if (message != null) "message": message, + if (errorType != null) "error_type": errorType, + if (extraProps != null) ...extraProps, + }; + return _logEvent(evt, eventProperties: props); + } + + static Future paymentPrepared(double amount, double fee, + bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async => + _paymentEvent("paymentPrepared", amount, fee, payerCoversFee, source, recpientPaymentMethod); + //TODO Rework paymentStarted (do i need all those properties or is the event enough? Mb properties should be passed at paymentPrepared) + static Future paymentStarted(double amount, double fee, + bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async => + _paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod); + + static Future paymentFailed(double amount, double fee, bool payerCoversFee, + PaymentType source, PaymentType recpientPaymentMethod, String errorType, String message) async => + _paymentEvent("paymentFailed", amount, fee, payerCoversFee, source, recpientPaymentMethod, + errorType: errorType, message: message); + + static Future paymentError(double amount, double fee, bool payerCoversFee, + PaymentType source,PaymentType recpientPaymentMethod, String message) async => + _paymentEvent("paymentError", amount, fee, payerCoversFee, source, recpientPaymentMethod, + message: message); + + static Future paymentSuccess({ + required double amount, + required double fee, + required bool payerCoversFee, + required PaymentType source, + required PaymentType recpientPaymentMethod, + required String transactionId, + String? comment, + required int durationMs, + }) async { + return _paymentEvent( + "paymentSuccess", + amount, + fee, + payerCoversFee, + source, + recpientPaymentMethod, + message: comment, + extraProps: { + "transaction_id": transactionId, + "duration_ms": durationMs, //How do i calculate duration here? + "\$revenue": amount, //How do i calculate revenue here? + "\$revenueType": "payment", //Do we need to get revenue type? + }, + ); + } + + //TODO add when support is ready + // static Future supportOpened(String fromPage, String trigger) async => + // _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger}); + + // static Future supportMessageSent(String category, bool resolved) async => + // _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved}); + + + static Future walletTopUp(double amount, PaymentType method) async => + _logEvent("walletTopUp", eventProperties: {"amount": amount, "method": method}); + + + //TODO Decide do we need uiElementClicked or pageOpened is enough? + static Future uiElementClicked(String elementName, String page, String uiSource) async => + _logEvent("uiElementClicked", eventProperties: { + "element_name": elementName, + "page": page, + "uiSource": uiSource + }); + + static final Map _stepStartTimes = {}; + //TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly + static Future stepStarted(String stepName, {String? context}) async { + _stepStartTimes[stepName] = DateTime.now().millisecondsSinceEpoch; + return _logEvent("stepStarted", eventProperties: { + "step_name": stepName, + if (context != null) "context": context, + }); + } + + static Future stepCompleted(String stepName, bool success) async { + final now = DateTime.now().millisecondsSinceEpoch; + final start = _stepStartTimes[stepName] ?? now; + final duration = now - start; + return _logEvent("stepCompleted", eventProperties: { + "step_name": stepName, + "duration_ms": duration, + "success": success + }); + } + + static Future _logEvent( + String eventType, { + Map? eventProperties, + Map? userProperties, + }) async { + final event = BaseEvent( + eventType, + eventProperties: eventProperties, + userProperties: userProperties, + ); + _amp().track(event); + print(event.toString()); //TODO delete when everything is ready + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/services/auth.dart b/frontend/pweb/lib/services/auth.dart new file mode 100644 index 0000000..95a75e7 --- /dev/null +++ b/frontend/pweb/lib/services/auth.dart @@ -0,0 +1,12 @@ + +class AuthenticationService { + Future verifyTwoFactorCode(String code) async { + await Future.delayed(const Duration(seconds: 2)); + + if (code == '000000') { + return true; + } else { + throw Exception('Wrong Code'); //TODO Localize + } + } +} diff --git a/frontend/pweb/lib/services/balance.dart b/frontend/pweb/lib/services/balance.dart new file mode 100644 index 0000000..8587afa --- /dev/null +++ b/frontend/pweb/lib/services/balance.dart @@ -0,0 +1,22 @@ +abstract class BalanceService { + Future getBalance(); + Future getWalletName(); + Future getWalletId(); +} + +class MockBalanceService implements BalanceService { + @override + Future getBalance() async { + return 3000000.56; + } + + @override + Future getWalletName() async { + return "Wallet"; + } + + @override + Future getWalletId() async { + return "WA-12345667"; + } +} diff --git a/frontend/pweb/lib/services/payments/payment_methods.dart b/frontend/pweb/lib/services/payments/payment_methods.dart new file mode 100644 index 0000000..5946b93 --- /dev/null +++ b/frontend/pweb/lib/services/payments/payment_methods.dart @@ -0,0 +1,42 @@ +import 'package:pshared/models/payment/methods/type.dart'; + +import 'package:pshared/models/payment/type.dart'; + + +abstract class PaymentMethodsService { + Future> fetchMethods(); +} + +class MockPaymentMethodsService implements PaymentMethodsService { + @override + Future> fetchMethods() async { + await Future.delayed(const Duration(milliseconds: 200)); + return [ + PaymentMethod( + id: '1', + label: 'My account', + details: '•••4567', + type: PaymentType.bankAccount, + isMain: true, + ), + PaymentMethod( + id: '2', + label: 'Euro IBAN', + details: 'DE•• •••8901', + type: PaymentType.iban, + ), + PaymentMethod( + id: '3', + label: 'Wallet', + details: 'WA‑12345667', + type: PaymentType.wallet, + ), + PaymentMethod( + id: '4', + label: 'Credit Card', + details: '21•• •••• •••• 8901', + type: PaymentType.card, + ), + ]; + } +} diff --git a/frontend/pweb/lib/services/payments/upload_history.dart b/frontend/pweb/lib/services/payments/upload_history.dart new file mode 100644 index 0000000..a4e1285 --- /dev/null +++ b/frontend/pweb/lib/services/payments/upload_history.dart @@ -0,0 +1,20 @@ +import 'package:pshared/models/payment/upload_history_item.dart'; + + +abstract class UploadHistoryService { + Future> fetchHistory(); +} + +class MockUploadHistoryService implements UploadHistoryService { + @override + Future> fetchHistory() async { + await Future.delayed(const Duration(milliseconds: 300)); + + return [ + UploadHistoryItem(name: "cards_payout_single.csv", status: "Valid", time: "5 hours ago"), + UploadHistoryItem(name: "rfba_norm.csv", status: "Valid", time: "Yesterday"), + UploadHistoryItem(name: "iban (4).csv", status: "Valid", time: "Yesterday"), + UploadHistoryItem(name: "rfba_wrong.csv", status: "Error", time: "2 days ago"), + ]; + } +} diff --git a/frontend/pweb/lib/services/recipient/recipient.dart b/frontend/pweb/lib/services/recipient/recipient.dart new file mode 100644 index 0000000..7c4cc68 --- /dev/null +++ b/frontend/pweb/lib/services/recipient/recipient.dart @@ -0,0 +1,88 @@ +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/models/payment/methods/card.dart'; +import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/russian_bank.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/recipient/status.dart'; +import 'package:pshared/models/recipient/type.dart'; + + +class RecipientService { + Future> fetchRecipients() async { + await Future.delayed(const Duration(milliseconds: 500)); + return RecipientMockData.all; + } +} + +class RecipientMockData { + static List get all => [ + Recipient.mock( + name: 'Alice Johnson', + email: 'alice@example.com', + status: RecipientStatus.ready, + type: RecipientType.internal, + card: CardPaymentMethod( + pan: '1213', + firstName: 'Alice', + lastName: 'Johnson', + ), + ), + Recipient.mock( + name: 'Bob & Co Ltd.', + email: 'payout@bobco.com', + status: RecipientStatus.registered, + type: RecipientType.external, + card: CardPaymentMethod( + pan: '4343', + firstName: 'Bob', + lastName: 'Co', + ), + iban: IbanPaymentMethod( + iban: 'FR7630***890189', + accountHolder: 'Bob & Co Ltd.', + bic: 'AGRIFRPP', + bankName: 'Credit Agricole', + ), + wallet: WalletPaymentMethod(walletId: '8932231'), + ), + Recipient.mock( + name: 'Carlos Kline', + email: 'carlos@acme.org', + status: RecipientStatus.notRegistered, + type: RecipientType.internal, + wallet: WalletPaymentMethod(walletId: '7723490'), + ), + Recipient.mock( + name: 'Delta Outsourcing GmbH', + email: 'finance@delta-os.de', + status: RecipientStatus.registered, + type: RecipientType.external, + card: CardPaymentMethod( + pan: '9988', + firstName: 'Delta', + lastName: 'GmbH', + ), + iban: IbanPaymentMethod( + iban: 'DE4450***324931', + accountHolder: 'Delta Outsourcing GmbH', + bic: 'INGDDEFFXXX', + bankName: 'ING', + ), + ), + Recipient.mock( + name: 'Erin Patel', + email: 'erin@labster.io', + status: RecipientStatus.ready, + type: RecipientType.internal, + bank: RussianBankAccountPaymentMethod( + accountNumber: '4081***7654', + recipientName: 'Erin Patel', + inn: '7812012345', + kpp: '781201001', + bankName: 'Alfa-Bank', + bik: '044525593', + correspondentAccount: '30101810200000000593', + ), + ), + ]; +} diff --git a/frontend/pweb/lib/services/wallets.dart b/frontend/pweb/lib/services/wallets.dart new file mode 100644 index 0000000..7f5d033 --- /dev/null +++ b/frontend/pweb/lib/services/wallets.dart @@ -0,0 +1,41 @@ +import 'package:pweb/models/currency.dart'; +import 'package:pweb/models/wallet.dart'; + + +abstract class WalletsService { + Future> getWallets(); + Future> updateWallet(); + Future> createWallet(); + Future> deleteWallet(); + + Future getWallet(String walletRef); +} + +class MockWalletsService implements WalletsService { + final List _wallets = [ + Wallet(id: '1124', walletUserID: 'WA-12345667', name: 'Main Wallet', balance: 10000000.0, currency: Currency.rub), + Wallet(id: '2124', walletUserID: 'WA-76654321', name: 'Savings', balance: 2500.5, currency: Currency.usd), + ]; + + @override + Future> getWallets() async { + return _wallets; + } + + @override + Future getWallet(String walletId) async { + return _wallets.firstWhere( + (wallet) => wallet.id == walletId, + orElse: () => throw Exception('Wallet not found'), + ); + } + + @override + Future> updateWallet() async => []; + + @override + Future> createWallet() async => []; + + @override + Future> deleteWallet() async => []; +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/clipboard.dart b/frontend/pweb/lib/utils/clipboard.dart new file mode 100644 index 0000000..9b71a63 --- /dev/null +++ b/frontend/pweb/lib/utils/clipboard.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:pshared/utils/snackbar.dart'; + + +Future copyToClipboard(BuildContext context, String text, String hint, {int delaySeconds = 3}) async { + final res = Clipboard.setData(ClipboardData(text: text)); + notifyUser(context, hint, delaySeconds: delaySeconds); + return res; +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/currency.dart b/frontend/pweb/lib/utils/currency.dart new file mode 100644 index 0000000..7f9a33d --- /dev/null +++ b/frontend/pweb/lib/utils/currency.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/currency.dart'; + + +String currencyCodeToSymbol(Currency currencyCode) { + switch (currencyCode) { + case Currency.usd: + return '\$'; + case Currency.eur: + return '€'; + case Currency.rub: + return '₽'; + case Currency.usdt: + return 'USDT'; + case Currency.usdc: + return 'USDC'; + } +} + +String currencyToString(Currency currencyCode, double amount) { + return '${amount.toStringAsFixed(2)} ${currencyCodeToSymbol(currencyCode)}'; +} + +IconData iconForCurrencyType(Currency currencyCode) { + switch (currencyCode) { + case Currency.usd: + return Icons.currency_exchange; + case Currency.eur: + return Icons.currency_exchange; + case Currency.rub: + return Icons.currency_ruble; + case Currency.usdt: + return Icons.currency_exchange; + case Currency.usdc: + return Icons.money; + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/dimensions.dart b/frontend/pweb/lib/utils/dimensions.dart new file mode 100644 index 0000000..cb7ff4a --- /dev/null +++ b/frontend/pweb/lib/utils/dimensions.dart @@ -0,0 +1,48 @@ +class AppDimensions { + final double paddingSmall; + final double paddingMedium; + final double paddingLarge; + final double paddingXLarge; + final double paddingXXLarge; + final double paddingXXXLarge; + + final double spacingSmall; + + final double borderRadiusSmall; + final double borderRadiusMedium; + + final double maxContentWidth; + final double buttonWidth; + final double buttonHeight; + + final double iconSizeLarge; + final double iconSizeMedium; + final double iconSizeSmall; + + final double elevationSmall; + + const AppDimensions({ + this.paddingSmall = 8, + this.paddingMedium = 12, + this.paddingLarge = 16, + this.paddingXLarge = 20, + this.paddingXXLarge = 25, + this.paddingXXXLarge = 30, + + this.spacingSmall = 5, + + + this.borderRadiusSmall = 12, + this.borderRadiusMedium = 16, + + this.maxContentWidth = 500, + this.buttonWidth = 300, + this.buttonHeight = 40, + + this.iconSizeLarge = 30, + this.iconSizeMedium = 24, + this.iconSizeSmall = 20, + + this.elevationSmall = 4, + }); +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/error/content.dart b/frontend/pweb/lib/utils/error/content.dart new file mode 100644 index 0000000..35fb7a1 --- /dev/null +++ b/frontend/pweb/lib/utils/error/content.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + + +class ErrorSnackBarContent extends StatelessWidget { + final String situation; + final String localizedError; + + const ErrorSnackBarContent({ + super.key, + required this.situation, + required this.localizedError, + }); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, // wrap to content + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + situation, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text(localizedError), + ], + ); +} diff --git a/frontend/pweb/lib/utils/error/handler.dart b/frontend/pweb/lib/utils/error/handler.dart new file mode 100644 index 0000000..2313b4f --- /dev/null +++ b/frontend/pweb/lib/utils/error/handler.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/api/responses/error/connectivity.dart'; +import 'package:pshared/api/responses/error/server.dart'; +import 'package:pshared/config/constants.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ErrorHandler { + /// A mapping of server-side error codes to localized user-friendly messages. + /// Update these keys to match the 'ErrorResponse.Error' field in your Go code. + static Map getErrorMessagesLocs(AppLocalizations locs) { + return { + 'account_not_verified': locs.errorAccountNotVerified, + 'unauthorized': locs.errorLoginUnauthorized, + 'verification_token_not_found': locs.errorVerificationTokenNotFound, + 'internal_error': locs.errorInternalError, + + 'data_conflict': locs.errorDataConflict, + 'access_denied': locs.errorAccessDenied, + 'broken_payload': locs.errorBrokenPayload, + 'invalid_argument': locs.errorInvalidArgument, + 'broken_reference': locs.errorBrokenReference, + 'invalid_query_parameter': locs.errorInvalidQueryParameter, + 'not_implemented': locs.errorNotImplemented, + 'license_required': locs.errorLicenseRequired, + 'not_found': locs.errorNotFound, + 'name_missing': locs.errorNameMissing, + 'email_missing': locs.errorEmailMissing, + 'password_missing': locs.errorPasswordMissing, + 'email_not_registered': locs.errorEmailNotRegistered, + 'duplicate_email': locs.errorDuplicateEmail, + }; + } + + static Map getErrorMessages(BuildContext context) { + return getErrorMessagesLocs(AppLocalizations.of(context)!); + } + + /// Determine which handler to use based on the runtime type of [e]. + /// If no match is found, just return the error’s string representation. + static String handleError(BuildContext context, Object e) { + return handleErrorLocs(AppLocalizations.of(context)!, e); + } + + static String handleErrorLocs(AppLocalizations locs, Object e) { + final errorHandlers = { + ErrorResponse: (ex) => _handleErrorResponseLocs(locs, ex as ErrorResponse), + ConnectivityError: (ex) => _handleConnectivityErrorLocs(locs, ex as ConnectivityError), + }; + + return errorHandlers[e.runtimeType]?.call(e) ?? e.toString(); + } + + static String _handleErrorResponseLocs(AppLocalizations locs, ErrorResponse e) { + final errorMessages = getErrorMessagesLocs(locs); + // Return the localized message if we recognize the error key, else use the raw details + return errorMessages[e.error] ?? e.details; + } + + /// Handler for connectivity issues. + static String _handleConnectivityErrorLocs(AppLocalizations locs, ConnectivityError e) { + return locs.connectivityError(Constants.serviceUrl); + } +} diff --git a/frontend/pweb/lib/utils/error/snackbar.dart b/frontend/pweb/lib/utils/error/snackbar.dart new file mode 100644 index 0000000..ca98a9d --- /dev/null +++ b/frontend/pweb/lib/utils/error/snackbar.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/error/handler.dart'; +import 'package:pweb/widgets/error/content.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +ScaffoldFeatureController notifyUserOfErrorX({ + required ScaffoldMessengerState scaffoldMessenger, + required String errorSituation, + required Object exception, + required AppLocalizations appLocalizations, + int delaySeconds = 3, +}) { + // A. Localized user-friendly error message + final String localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); + + // B. Technical details for advanced reference + final String technicalDetails = exception.toString(); + + // C. Build the snack bar + final snackBar = _buildMainErrorSnackBar( + errorSituation: errorSituation, + localizedError: localizedError, + technicalDetails: technicalDetails, + loc: appLocalizations, + scaffoldMessenger: scaffoldMessenger, + delaySeconds: delaySeconds, + ); + + // D. Show it + return scaffoldMessenger.showSnackBar(snackBar); +} + +ScaffoldFeatureController notifyUserOfError({ + required BuildContext context, + required String errorSituation, + required Object exception, + int delaySeconds = 3, +}) => notifyUserOfErrorX( + scaffoldMessenger: ScaffoldMessenger.of(context), + errorSituation: errorSituation, + exception: exception, + appLocalizations: AppLocalizations.of(context)!, + delaySeconds: delaySeconds, +); + +Future executeActionWithNotification({ + required BuildContext context, + required Future Function() action, + required String errorMessage, + int delaySeconds = 3, +}) async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final localizations = AppLocalizations.of(context)!; + + try { + return await action(); + } catch (e) { + // Report the error using your existing notifier. + notifyUserOfErrorX( + scaffoldMessenger: scaffoldMessenger, + errorSituation: errorMessage, + exception: e, + appLocalizations: localizations, + delaySeconds: delaySeconds, + ); + } + return null; +} + +Future> postNotifyUserOfError({ + required ScaffoldMessengerState scaffoldMessenger, + required String errorSituation, + required Object exception, + required AppLocalizations appLocalizations, + int delaySeconds = 3, +}) { + + final completer = Completer>(); + + WidgetsBinding.instance.addPostFrameCallback((_) => completer.complete(notifyUserOfErrorX( + scaffoldMessenger: scaffoldMessenger, + errorSituation: errorSituation, + exception: exception, + appLocalizations: appLocalizations, + delaySeconds: delaySeconds, + )), + ); + + return completer.future; +} + +Future> postNotifyUserOfErrorX({ + required BuildContext context, + required String errorSituation, + required Object exception, + int delaySeconds = 3, +}) => postNotifyUserOfError( + scaffoldMessenger: ScaffoldMessenger.of(context), + errorSituation: errorSituation, + exception: exception, + appLocalizations: AppLocalizations.of(context)!, + delaySeconds: delaySeconds, +); + + +/// 2) A helper function that returns the main SnackBar widget +SnackBar _buildMainErrorSnackBar({ + required String errorSituation, + required String localizedError, + required String technicalDetails, + required AppLocalizations loc, + required ScaffoldMessengerState scaffoldMessenger, + int delaySeconds = 3, +}) => SnackBar( + duration: Duration(seconds: delaySeconds), + content: ErrorSnackBarContent( + situation: errorSituation, + localizedError: localizedError, + ), + action: SnackBarAction( + label: loc.showDetailsAction, + onPressed: () => scaffoldMessenger.showSnackBar(SnackBar( + content: Text(technicalDetails), + duration: const Duration(seconds: 6), + )), + ), +); diff --git a/frontend/pweb/lib/utils/error_handler.dart b/frontend/pweb/lib/utils/error_handler.dart new file mode 100644 index 0000000..2313b4f --- /dev/null +++ b/frontend/pweb/lib/utils/error_handler.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/api/responses/error/connectivity.dart'; +import 'package:pshared/api/responses/error/server.dart'; +import 'package:pshared/config/constants.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ErrorHandler { + /// A mapping of server-side error codes to localized user-friendly messages. + /// Update these keys to match the 'ErrorResponse.Error' field in your Go code. + static Map getErrorMessagesLocs(AppLocalizations locs) { + return { + 'account_not_verified': locs.errorAccountNotVerified, + 'unauthorized': locs.errorLoginUnauthorized, + 'verification_token_not_found': locs.errorVerificationTokenNotFound, + 'internal_error': locs.errorInternalError, + + 'data_conflict': locs.errorDataConflict, + 'access_denied': locs.errorAccessDenied, + 'broken_payload': locs.errorBrokenPayload, + 'invalid_argument': locs.errorInvalidArgument, + 'broken_reference': locs.errorBrokenReference, + 'invalid_query_parameter': locs.errorInvalidQueryParameter, + 'not_implemented': locs.errorNotImplemented, + 'license_required': locs.errorLicenseRequired, + 'not_found': locs.errorNotFound, + 'name_missing': locs.errorNameMissing, + 'email_missing': locs.errorEmailMissing, + 'password_missing': locs.errorPasswordMissing, + 'email_not_registered': locs.errorEmailNotRegistered, + 'duplicate_email': locs.errorDuplicateEmail, + }; + } + + static Map getErrorMessages(BuildContext context) { + return getErrorMessagesLocs(AppLocalizations.of(context)!); + } + + /// Determine which handler to use based on the runtime type of [e]. + /// If no match is found, just return the error’s string representation. + static String handleError(BuildContext context, Object e) { + return handleErrorLocs(AppLocalizations.of(context)!, e); + } + + static String handleErrorLocs(AppLocalizations locs, Object e) { + final errorHandlers = { + ErrorResponse: (ex) => _handleErrorResponseLocs(locs, ex as ErrorResponse), + ConnectivityError: (ex) => _handleConnectivityErrorLocs(locs, ex as ConnectivityError), + }; + + return errorHandlers[e.runtimeType]?.call(e) ?? e.toString(); + } + + static String _handleErrorResponseLocs(AppLocalizations locs, ErrorResponse e) { + final errorMessages = getErrorMessagesLocs(locs); + // Return the localized message if we recognize the error key, else use the raw details + return errorMessages[e.error] ?? e.details; + } + + /// Handler for connectivity issues. + static String _handleConnectivityErrorLocs(AppLocalizations locs, ConnectivityError e) { + return locs.connectivityError(Constants.serviceUrl); + } +} diff --git a/frontend/pweb/lib/utils/flagged_locale.dart b/frontend/pweb/lib/utils/flagged_locale.dart new file mode 100644 index 0000000..ad55a78 --- /dev/null +++ b/frontend/pweb/lib/utils/flagged_locale.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:country_flags/country_flags.dart'; + + +String _locale2Flag(Locale l) { + if (l.languageCode == 'en') { + return 'gb'; + } + if (l.languageCode == 'uk') { + return 'ua'; + } + if (l.languageCode == 'el') { + return 'gr'; + } + return l.languageCode; +} + +final Map localeNames = { + 'en': 'English', + 'es': 'Español', + 'fr': 'Français', + 'de': 'Deutsch', + 'uk': 'Українська', + 'el': 'Ελληνικά', + 'ru': 'Русский', + 'pt': 'Português', + 'pl': 'Polski', + 'it': 'Italiano', + 'nl': 'Nederlands', +}; + +Widget getCountryFlag(Locale locale) { + return + CountryFlag.fromCountryCode( + _locale2Flag(locale), + height: 24, + width: 30, + shape: Rectangle(), + ); +} + +String getLocaleName(Locale locale) { + return localeNames[locale.languageCode] ?? Intl.canonicalizedLocale(locale.toString()).toUpperCase(); +} + +Widget getFlaggedLocale(Locale locale) { + return ListTile( + leading: getCountryFlag(locale), + title: Text(getLocaleName(locale), overflow: TextOverflow.ellipsis), + ); +} + diff --git a/frontend/pweb/lib/utils/http.dart b/frontend/pweb/lib/utils/http.dart new file mode 100644 index 0000000..7720fab --- /dev/null +++ b/frontend/pweb/lib/utils/http.dart @@ -0,0 +1,12 @@ +// ignore: avoid_web_libraries_in_flutter +import 'package:web/web.dart' as web; + + +String getUrl() { + return web.window.location.href; +} + +String? getQueryParameter(String queryParameter) { + Uri uri = Uri.parse(getUrl()); + return uri.queryParameters[queryParameter]; +} diff --git a/frontend/pweb/lib/utils/initials.dart b/frontend/pweb/lib/utils/initials.dart new file mode 100644 index 0000000..de7c803 --- /dev/null +++ b/frontend/pweb/lib/utils/initials.dart @@ -0,0 +1,5 @@ +String getInitials(String name) { + final parts = name.trim().split(' '); + if (parts.length == 1) return parts[0][0].toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/notify.dart b/frontend/pweb/lib/utils/notify.dart new file mode 100644 index 0000000..3703111 --- /dev/null +++ b/frontend/pweb/lib/utils/notify.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/utils/snackbar.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + + +Future invokeAndNotify( + BuildContext context, { + required Future Function() operation, + String? operationSuccess, + String? operationError, + void Function(Object)? onError, + void Function(T)? onSuccess, +}) async { + final sm = ScaffoldMessenger.of(context); + final locs = AppLocalizations.of(context)!; + try { + final res = await operation(); + if (operationSuccess != null) { + notifyUserX(sm, operationSuccess); + } + if (onSuccess != null) { + onSuccess(res); + } + } catch (e) { + notifyUserOfErrorX( + scaffoldMessenger: sm, + errorSituation: operationError ?? locs.errorInternalError, + exception: e, + appLocalizations: locs, + ); + if (onError != null) { + onError(e); + } + rethrow; + } +} diff --git a/frontend/pweb/lib/utils/payment/dropdown.dart b/frontend/pweb/lib/utils/payment/dropdown.dart new file mode 100644 index 0000000..c56bd8e --- /dev/null +++ b/frontend/pweb/lib/utils/payment/dropdown.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/methods/type.dart'; + +import 'package:pweb/pages/payment_methods/icon.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentMethodDropdown extends StatefulWidget { + final List methods; + final ValueChanged onChanged; + final PaymentMethod? initialValue; + + const PaymentMethodDropdown({ + super.key, + required this.methods, + required this.onChanged, + this.initialValue, + }); + + @override + State createState() => _PaymentMethodDropdownState(); +} + +class _PaymentMethodDropdownState extends State { + late PaymentMethod _selectedMethod; + + @override + void initState() { + super.initState(); + _selectedMethod = widget.initialValue ?? widget.methods.first; + } + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + dropdownColor: Theme.of(context).colorScheme.onSecondary, + value: _selectedMethod, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.whereGetMoney, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: widget.methods.map((method) { + return DropdownMenuItem( + value: method, + child: Row( + children: [ + Icon(iconForPaymentType(method.type), size: 20), + const SizedBox(width: 8), + Text('${method.label} (${method.details})'), + ], + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedMethod = value); + widget.onChanged(value); + } + }, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/payment/label.dart b/frontend/pweb/lib/utils/payment/label.dart new file mode 100644 index 0000000..11b040b --- /dev/null +++ b/frontend/pweb/lib/utils/payment/label.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String getPaymentTypeLabel(BuildContext context, PaymentType type) { + final l10n = AppLocalizations.of(context)!; + return switch (type) { + PaymentType.card => l10n.paymentTypeCard, + PaymentType.bankAccount => l10n.paymentTypeBankAccount, + PaymentType.iban => l10n.paymentTypeIban, + PaymentType.wallet => l10n.paymentTypeWallet, + }; +} diff --git a/frontend/pweb/lib/utils/payment/selector_type.dart b/frontend/pweb/lib/utils/payment/selector_type.dart new file mode 100644 index 0000000..0680012 --- /dev/null +++ b/frontend/pweb/lib/utils/payment/selector_type.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/type.dart'; + +import 'package:pweb/utils/payment/label.dart'; + + +class PaymentTypeSelector extends StatelessWidget { + final Map availableTypes; + final PaymentType selectedType; + final ValueChanged onSelected; + + const PaymentTypeSelector({ + super.key, + required this.availableTypes, + required this.selectedType, + required this.onSelected, + }); + + static const double _chipSpacing = 12.0; + static const double _chipBorderRadius = 10.0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Wrap( + spacing: _chipSpacing, + runSpacing: _chipSpacing, + children: availableTypes.keys.map((type) { + final isSelected = selectedType == type; + + return ChoiceChip( + label: Text( + getPaymentTypeLabel(context, type), + style: theme.textTheme.titleMedium!.copyWith( + color: isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + selected: isSelected, + showCheckmark: false, + selectedColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.onSecondary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_chipBorderRadius), + ), + onSelected: (_) => onSelected(type), + ); + }).toList(), + ); + } +} diff --git a/frontend/pweb/lib/utils/share.dart b/frontend/pweb/lib/utils/share.dart new file mode 100644 index 0000000..1c3b0ed --- /dev/null +++ b/frontend/pweb/lib/utils/share.dart @@ -0,0 +1,40 @@ +// ignore: avoid_web_libraries_in_flutter +import 'package:web/web.dart' as web; + +import 'package:flutter/material.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:pweb/utils/clipboard.dart'; + + +enum DeviceType { desktop, mobile, unknown } + +DeviceType getDeviceType() { + final userAgent = web.window.navigator.userAgent; + if (userAgent.contains('Mobile') || userAgent.contains('Android') || userAgent.contains('iPhone')) { + return DeviceType.mobile; + } + + if (userAgent.contains('Windows') || userAgent.contains('Macintosh') || userAgent.contains('Linux')) { + return DeviceType.desktop; + } + + return DeviceType.unknown; +} + + + +Future share(BuildContext context, String content, String hint, String clipboardHint, {int delaySeconds = 1}) { + + if (getDeviceType() != DeviceType.desktop) { + final RenderBox box = context.findRenderObject() as RenderBox; + return SharePlus.instance.share(ShareParams( + text: content, + subject: hint, + sharePositionOrigin: box.localToGlobal(Offset.zero) & box.size, + )); + } + + return copyToClipboard(context, content, clipboardHint, delaySeconds: delaySeconds); +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/snackbar.dart b/frontend/pweb/lib/utils/snackbar.dart new file mode 100644 index 0000000..a8524ed --- /dev/null +++ b/frontend/pweb/lib/utils/snackbar.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + + +ScaffoldFeatureController notifyUserX( + ScaffoldMessengerState sm, + String message, + { int delaySeconds = 3 } +) => sm.showSnackBar(SnackBar(content: Text(message), duration: Duration(seconds: delaySeconds))); + +ScaffoldFeatureController notifyUser( + BuildContext context, + String message, + { int delaySeconds = 3 } +) => notifyUserX(ScaffoldMessenger.of(context), message, delaySeconds: delaySeconds); + +Future> postNotifyUser( + BuildContext context, String message, {int delaySeconds = 3}) { + + final completer = Completer>(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final controller = notifyUser(context, message, delaySeconds: delaySeconds); + completer.complete(controller); + }); + + return completer.future; +} diff --git a/frontend/pweb/lib/utils/snapshot_haserror_check.dart b/frontend/pweb/lib/utils/snapshot_haserror_check.dart new file mode 100644 index 0000000..e3ac51a --- /dev/null +++ b/frontend/pweb/lib/utils/snapshot_haserror_check.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'package:logging/logging.dart'; + + +bool hasError(AsyncSnapshot snapshot, String source) { + if (snapshot.hasError) { + Logger(source).warning('Error occurred', snapshot.error?.toString(), StackTrace.current); + return true; + } + return false; +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/text_field_styles.dart b/frontend/pweb/lib/utils/text_field_styles.dart new file mode 100644 index 0000000..fb59df8 --- /dev/null +++ b/frontend/pweb/lib/utils/text_field_styles.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +TextStyle getTextFieldStyle(BuildContext context, bool isEditable) { + return TextStyle( + color: isEditable + ? Theme.of(context).shadowColor + : Theme.of(context).disabledColor + ); +} + +InputDecoration getInputDecoration(BuildContext context, String label, bool isEditable) { + final theme = Theme.of(context); + return InputDecoration( + labelText: label, + labelStyle: TextStyle( + color: isEditable ? theme.shadowColor : theme.disabledColor, + ) + ); +} diff --git a/frontend/pweb/lib/utils/time_ago.dart b/frontend/pweb/lib/utils/time_ago.dart new file mode 100644 index 0000000..5505b09 --- /dev/null +++ b/frontend/pweb/lib/utils/time_ago.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +import 'package:timeago/timeago.dart' as timeago; + +import 'package:pshared/models/storable.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String timeAgo(BuildContext context, Storable storable) { + // Use updatedAt if available; otherwise, fallback to createdAt. + final timestamp = storable.updatedAt.isAfter(storable.createdAt) + ? storable.updatedAt + : storable.createdAt; + final timestampPrefix = storable.updatedAt.isAfter(storable.createdAt) + ? AppLocalizations.of(context)!.edited + : AppLocalizations.of(context)!.created; + + return '$timestampPrefix ${timeago.format(timestamp)}'; +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/appbar/app_bar.dart b/frontend/pweb/lib/widgets/appbar/app_bar.dart new file mode 100644 index 0000000..d5c8299 --- /dev/null +++ b/frontend/pweb/lib/widgets/appbar/app_bar.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/appbar/profile.dart'; +import 'package:pweb/widgets/logo.dart'; + + +class PayoutAppBar extends StatelessWidget implements PreferredSizeWidget { + const PayoutAppBar({ + super.key, + required this.title, + required this.onAddFundsPressed, + this.actions, + this.onLogout, + this.avatarUrl, + }); + + final Widget title; + final VoidCallback onAddFundsPressed; + final List? actions; + final VoidCallback? onLogout; + final String? avatarUrl; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 110, right: 80), + child: AppBar( + automaticallyImplyLeading: false, + title: Row( + children: [ + ServiceLogo(), + SizedBox(width: 16), + title, + ], + ), + // leading: Padding(padding: EdgeInsetsGeometry.symmetric(horizontal: 8, vertical: 8), child: ServiceLogo()), + actions: [ + ProfileAvatar( + avatarUrl: avatarUrl, + onLogout: onLogout, + ), + const SizedBox(width: 8), + ], + ), + ); +} diff --git a/frontend/pweb/lib/widgets/appbar/notifications.dart b/frontend/pweb/lib/widgets/appbar/notifications.dart new file mode 100644 index 0000000..e2c2000 --- /dev/null +++ b/frontend/pweb/lib/widgets/appbar/notifications.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class NotificationsButton extends StatelessWidget { + const NotificationsButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(Icons.notifications), + onPressed: null, + ); + } +} diff --git a/frontend/pweb/lib/widgets/appbar/profile.dart b/frontend/pweb/lib/widgets/appbar/profile.dart new file mode 100644 index 0000000..365395c --- /dev/null +++ b/frontend/pweb/lib/widgets/appbar/profile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({super.key, this.avatarUrl, this.onLogout}); + + final String? avatarUrl; + final VoidCallback? onLogout; + + @override + Widget build(BuildContext context) => PopupMenuButton( + tooltip: AppLocalizations.of(context)!.profile, + onSelected: (value) { + if (value == 1) onLogout?.call(); + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 1, + child: Row( + children: [ + Icon( + Icons.logout, + size: 20, + color: Theme.of(context).iconTheme.color, + ), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.logout), + ], + ), + ), + ], + child: CircleAvatar( + radius: 16, + foregroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, + child: avatarUrl == null ? const Icon(Icons.person, size: 24) : null, + ), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/constrained_form.dart b/frontend/pweb/lib/widgets/constrained_form.dart new file mode 100644 index 0000000..b01c584 --- /dev/null +++ b/frontend/pweb/lib/widgets/constrained_form.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + + +class ConstrainedForm extends StatelessWidget { + final GlobalKey formKey; + final List children; + final AutovalidateMode? autovalidateMode; + + const ConstrainedForm({ + super.key, + required this.formKey, + required this.children, + this.autovalidateMode, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + key: formKey, + autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: children, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/widgets/drawer/avatar.dart b/frontend/pweb/lib/widgets/drawer/avatar.dart new file mode 100644 index 0000000..11dc659 --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/avatar.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:pshared/provider/account.dart'; + + +class AccountAvatar extends StatelessWidget { + const AccountAvatar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) => UserAccountsDrawerHeader( + accountName: Text(provider.account?.name ?? 'John Doe'), + accountEmail: Text(provider.account?.login ?? 'john.doe@acme.com'), + currentAccountPicture: CircleAvatar( + backgroundImage: (provider.account?.avatarUrl?.isNotEmpty ?? false) + ? CachedNetworkImageProvider(provider.account!.avatarUrl!) + : null, + child: (provider.account?.avatarUrl?.isNotEmpty ?? false) + ? null + : const Icon(Icons.account_circle, size: 50), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/widgets/drawer/tiles/dashboard.dart b/frontend/pweb/lib/widgets/drawer/tiles/dashboard.dart new file mode 100644 index 0000000..07adaed --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/dashboard.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/app/router/pages.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class DashboardTile extends StatelessWidget { + const DashboardTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.dashboard), + title: Text(AppLocalizations.of(context)!.dashboard), + onTap: () => navigate(context, Pages.dashboard), + ); + } +} diff --git a/frontend/pweb/lib/widgets/drawer/tiles/logout.dart b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart new file mode 100644 index 0000000..fd4561a --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/logout.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/app/router/pages.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LogoutTile extends StatelessWidget { + const LogoutTile({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.logout), + title: Text(AppLocalizations.of(context)!.navigationLogout), + onTap: () => _logout(context), + ); + } + + void _logout(BuildContext context) { + Navigator.pop(context); + final accountProvider = Provider.of(context, listen: false); + accountProvider.logout(); + navigateAndReplace(context, Pages.login); + } +} diff --git a/frontend/pweb/lib/widgets/drawer/tiles/settings/permissions.dart b/frontend/pweb/lib/widgets/drawer/tiles/settings/permissions.dart new file mode 100644 index 0000000..c3dd35f --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/settings/permissions.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PermissionsSettingsTile extends StatelessWidget { + const PermissionsSettingsTile({ + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(AppLocalizations.of(context)!.navigationPermissionsSettings), + onTap: () {// ToDo: account settings + }, + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/drawer/tiles/settings/profile.dart b/frontend/pweb/lib/widgets/drawer/tiles/settings/profile.dart new file mode 100644 index 0000000..d39a2bf --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/settings/profile.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/app/router/pages.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class ProfileSettingsTile extends StatelessWidget { + const ProfileSettingsTile({ + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.settings), + title: Text(AppLocalizations.of(context)!.navigationAccountSettings), + onTap: () => navigateNamed(context, Pages.profile), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/drawer/tiles/settings/roles.dart b/frontend/pweb/lib/widgets/drawer/tiles/settings/roles.dart new file mode 100644 index 0000000..7387516 --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/settings/roles.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/app/router/pages.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class RolesSettingsTile extends StatelessWidget { + const RolesSettingsTile({ + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.manage_accounts), + title: Text(AppLocalizations.of(context)!.navigationRolesSettings), + onTap: () => navigateNamed(context, Pages.roles), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/drawer/tiles/settings/users.dart b/frontend/pweb/lib/widgets/drawer/tiles/settings/users.dart new file mode 100644 index 0000000..7b7f375 --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/tiles/settings/users.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/app/router/pages.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UsersSettingsTile extends StatelessWidget { + const UsersSettingsTile({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.people), + title: Text(AppLocalizations.of(context)!.navigationUsersSettings), + onTap: () => navigateNamed(context, Pages.users), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/drawer/widget.dart b/frontend/pweb/lib/widgets/drawer/widget.dart new file mode 100644 index 0000000..5649bbc --- /dev/null +++ b/frontend/pweb/lib/widgets/drawer/widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/resources.dart'; +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/widgets/drawer/avatar.dart'; +import 'package:pweb/widgets/drawer/tiles/dashboard.dart'; +import 'package:pweb/widgets/drawer/tiles/logout.dart'; +import 'package:pweb/widgets/drawer/tiles/settings/profile.dart'; +import 'package:pweb/widgets/drawer/tiles/settings/roles.dart'; +import 'package:pweb/widgets/drawer/tiles/settings/users.dart'; + + +class AppDrawer extends StatelessWidget { + const AppDrawer({super.key}); + + @override + Widget build(BuildContext context) => Drawer( + child: Consumer(builder:(context, provider, _) => + ListView( + padding: EdgeInsets.zero, + children: [ + // Shows user avatar / name / email, etc. + const AccountAvatar(), + + const DashboardTile(), + + // Profile & Settings + const Divider(), + if (provider.canAccessResource(ResourceType.accounts)) + const UsersSettingsTile(), + if (provider.canAccessResource(ResourceType.roles)) + const RolesSettingsTile(), + const ProfileSettingsTile(), // always available + + // Logout + const Divider(), + const LogoutTile(), + ], + ), + ), + ); +} diff --git a/frontend/pweb/lib/widgets/employee/avatar/provider.dart b/frontend/pweb/lib/widgets/employee/avatar/provider.dart new file mode 100644 index 0000000..2bcded3 --- /dev/null +++ b/frontend/pweb/lib/widgets/employee/avatar/provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/accounts/employees.dart'; + +import 'package:pweb/widgets/employee/avatar/widget.dart'; + + +class EmployeeAvatarProvider extends StatelessWidget { + final String? employeeRef; + final double? radius; + + const EmployeeAvatarProvider({ + super.key, + this.employeeRef, + this.radius, + }); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) => EmployeeAvatar( + radius: radius, + avatarUrl: provider.getEmployee(employeeRef)?.avatarUrl, + employeeName: provider.getEmployee(employeeRef)?.name ?? '', + )); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/employee/avatar/widget.dart b/frontend/pweb/lib/widgets/employee/avatar/widget.dart new file mode 100644 index 0000000..a10258e --- /dev/null +++ b/frontend/pweb/lib/widgets/employee/avatar/widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:pshared/utils/name_initials.dart'; + + +class EmployeeAvatar extends StatelessWidget { + final String? avatarUrl; + final String employeeName; + final double? radius; + + const EmployeeAvatar({ + super.key, + this.avatarUrl, + required this.employeeName, + this.radius, + }); + + @override + Widget build(BuildContext context) => CircleAvatar( + radius: radius, + backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(26), + backgroundImage: avatarUrl != null ? CachedNetworkImageProvider(avatarUrl!) : null, + child: avatarUrl == null + ? Text(getNameInitials(employeeName), style: Theme.of(context).textTheme.bodyMedium) + : null, + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/employee/provider.dart b/frontend/pweb/lib/widgets/employee/provider.dart new file mode 100644 index 0000000..2263fcb --- /dev/null +++ b/frontend/pweb/lib/widgets/employee/provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/accounts/employees.dart'; + +import 'package:pweb/widgets/employee/tile.dart'; + + +class EmployeeTileProvider extends StatelessWidget { + final String? employeeRef; + final double? avatarRadius; + final Widget? trailing; + + const EmployeeTileProvider({super.key, required this.employeeRef, this.avatarRadius, this.trailing}); + + @override + Widget build(BuildContext context) => Consumer(builder: (context, provider, _) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + return EmployeeTile.fromEmployee( + context: context, + employee: provider.getEmployee(employeeRef), + avatarRadius: avatarRadius, + ); + }); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/employee/tile.dart b/frontend/pweb/lib/widgets/employee/tile.dart new file mode 100644 index 0000000..7ca5c2e --- /dev/null +++ b/frontend/pweb/lib/widgets/employee/tile.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/account/account.dart'; + +import 'package:pweb/widgets/employee/avatar/widget.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class EmployeeTile extends StatelessWidget { + final String name; + final String? avatarUrl; + final double? avatarRadius; + final Widget? trailing; + + const EmployeeTile({super.key, required this.name, this.avatarUrl, this.avatarRadius, this.trailing}); + + factory EmployeeTile.fromEmployee({ + required BuildContext context, + Account? employee, + double? avatarRadius + }) => EmployeeTile( + name: employee?.name ?? AppLocalizations.of(context)!.unknown, + avatarUrl: employee?.avatarUrl, + avatarRadius: avatarRadius, + ); + + @override + Widget build(BuildContext context) => ListTile( + leading: EmployeeAvatar(avatarUrl: avatarUrl, employeeName: name, radius: avatarRadius), + title: Text(name), + trailing: trailing, + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/error/content.dart b/frontend/pweb/lib/widgets/error/content.dart new file mode 100644 index 0000000..2a4ba92 --- /dev/null +++ b/frontend/pweb/lib/widgets/error/content.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/vspacer.dart'; + + +class ErrorSnackBarContent extends StatelessWidget { + final String situation; + final String localizedError; + + const ErrorSnackBarContent({ + super.key, + required this.situation, + required this.localizedError, + }); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, // wrap to content + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + situation, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const VSpacer(multiplier: 0.25), + Text(localizedError), + ], + ); +} diff --git a/frontend/pweb/lib/widgets/error/snackbar.dart b/frontend/pweb/lib/widgets/error/snackbar.dart new file mode 100644 index 0000000..9be921e --- /dev/null +++ b/frontend/pweb/lib/widgets/error/snackbar.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/error_handler.dart'; +import 'package:pweb/widgets/error/content.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +ScaffoldFeatureController notifyUserOfErrorX({ + required ScaffoldMessengerState scaffoldMessenger, + required String errorSituation, + required Object exception, + required AppLocalizations appLocalizations, + int delaySeconds = 3, +}) { + // A. Localized user-friendly error message + final String localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception); + + // B. Technical details for advanced reference + final String technicalDetails = exception.toString(); + + // C. Build the snack bar + final snackBar = _buildMainErrorSnackBar( + errorSituation: errorSituation, + localizedError: localizedError, + technicalDetails: technicalDetails, + loc: appLocalizations, + scaffoldMessenger: scaffoldMessenger, + delaySeconds: delaySeconds, + ); + + // D. Show it + return scaffoldMessenger.showSnackBar(snackBar); +} + +ScaffoldFeatureController notifyUserOfError({ + required BuildContext context, + required String errorSituation, + required Object exception, + int delaySeconds = 3, +}) => notifyUserOfErrorX( + scaffoldMessenger: ScaffoldMessenger.of(context), + errorSituation: errorSituation, + exception: exception, + appLocalizations: AppLocalizations.of(context)!, + delaySeconds: delaySeconds, +); + +Future executeActionWithNotification({ + required BuildContext context, + required Future Function() action, + required String errorMessage, + int delaySeconds = 3, +}) async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final localizations = AppLocalizations.of(context)!; + + try { + return await action(); + } catch (e) { + // Report the error using your existing notifier. + notifyUserOfErrorX( + scaffoldMessenger: scaffoldMessenger, + errorSituation: errorMessage, + exception: e, + appLocalizations: localizations, + delaySeconds: delaySeconds, + ); + } + return null; +} + +Future> postNotifyUserOfError({ + required ScaffoldMessengerState scaffoldMessenger, + required String errorSituation, + required Object exception, + required AppLocalizations appLocalizations, + int delaySeconds = 3, +}) { + + final completer = Completer>(); + + WidgetsBinding.instance.addPostFrameCallback((_) => completer.complete(notifyUserOfErrorX( + scaffoldMessenger: scaffoldMessenger, + errorSituation: errorSituation, + exception: exception, + appLocalizations: appLocalizations, + delaySeconds: delaySeconds, + )), + ); + + return completer.future; +} + +Future> postNotifyUserOfErrorX({ + required BuildContext context, + required String errorSituation, + required Object exception, + int delaySeconds = 3, +}) => postNotifyUserOfError( + scaffoldMessenger: ScaffoldMessenger.of(context), + errorSituation: errorSituation, + exception: exception, + appLocalizations: AppLocalizations.of(context)!, + delaySeconds: delaySeconds, +); + + +/// 2) A helper function that returns the main SnackBar widget +SnackBar _buildMainErrorSnackBar({ + required String errorSituation, + required String localizedError, + required String technicalDetails, + required AppLocalizations loc, + required ScaffoldMessengerState scaffoldMessenger, + int delaySeconds = 3, +}) => SnackBar( + duration: Duration(seconds: delaySeconds), + content: ErrorSnackBarContent( + situation: errorSituation, + localizedError: localizedError, + ), + action: SnackBarAction( + label: loc.showDetailsAction, + onPressed: () => scaffoldMessenger.showSnackBar(SnackBar( + content: Text(technicalDetails), + duration: const Duration(seconds: 6), + )), + ), +); diff --git a/frontend/pweb/lib/widgets/footer/labels.dart b/frontend/pweb/lib/widgets/footer/labels.dart new file mode 100644 index 0000000..a7ab64c --- /dev/null +++ b/frontend/pweb/lib/widgets/footer/labels.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/footer/policies.dart'; +import 'package:pweb/widgets/footer/support.dart'; +import 'package:pweb/widgets/vspacer.dart'; + + +class FooterLabels extends StatelessWidget { + const FooterLabels({ + super.key, + }); + + @override + Widget build(BuildContext context) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SupportLabel(), + const VSpacer(multiplier: 0.25), + const PoliciesLabel(), + ], + ), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/footer/policies.dart b/frontend/pweb/lib/widgets/footer/policies.dart new file mode 100644 index 0000000..815a01b --- /dev/null +++ b/frontend/pweb/lib/widgets/footer/policies.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +class PoliciesLabel extends StatelessWidget { + const PoliciesLabel({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textTheme; + final localizations = AppLocalizations.of(context)!; + return Wrap( + spacing: 8, + children: [ + GestureDetector( + onTap: () { + // Navigate to Terms of Service + }, + child: Text( + localizations.footerTermsOfService, + style: theme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + const Text('|'), + GestureDetector( + onTap: () { + // Navigate to Privacy Policy + }, + child: Text( + localizations.footerPrivacyPolicy, + style: theme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + const Text('|'), + GestureDetector( + onTap: () { + // Navigate to Cookie Policy + }, + child: Text( + localizations.footerCookiePolicy, + style: theme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/footer/support.dart b/frontend/pweb/lib/widgets/footer/support.dart new file mode 100644 index 0000000..711fd96 --- /dev/null +++ b/frontend/pweb/lib/widgets/footer/support.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/hspacer.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SupportLabel extends StatelessWidget { + const SupportLabel({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textTheme; + final localizations = AppLocalizations.of(context)!; + return Row( + children: [ + Row( + children: [ + Text( + '${localizations.footerSupport}: ', + style: theme.labelSmall, + ), + GestureDetector( + onTap: () { + // Add your email handling logic here + }, + child: Text( + localizations.footerEmail, // Localized email + style: theme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + const HSpacer(multiplier: 0.25), + const Text('|'), + const HSpacer(multiplier: 0.25), + Text( + '${localizations.footerPhoneLabel}: ${localizations.footerPhone}', // Localized phone + style: theme.labelSmall, + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/footer/widget.dart b/frontend/pweb/lib/widgets/footer/widget.dart new file mode 100644 index 0000000..ebb4866 --- /dev/null +++ b/frontend/pweb/lib/widgets/footer/widget.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/footer/labels.dart'; +import 'package:pweb/widgets/logo.dart'; +import 'package:pweb/widgets/hspacer.dart'; + + +class FooterWidget extends StatelessWidget { + const FooterWidget({super.key}); + + @override + Widget build(BuildContext context) => ClipRect( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const ServiceLogo(), + const HSpacer(), + const FooterLabels(), + ], + ), + ), + ); +} + + diff --git a/frontend/pweb/lib/widgets/hspacer.dart b/frontend/pweb/lib/widgets/hspacer.dart new file mode 100644 index 0000000..aeeeeb8 --- /dev/null +++ b/frontend/pweb/lib/widgets/hspacer.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + + +class HSpacer extends StatelessWidget{ + final double spacing; + final double multiplier; + + const HSpacer({super.key, this.spacing = 16, this.multiplier = 1.0}); + + @override + Widget build(BuildContext context) { + return SizedBox(width: spacing * multiplier); + } +} diff --git a/frontend/pweb/lib/widgets/logo.dart b/frontend/pweb/lib/widgets/logo.dart new file mode 100644 index 0000000..feca497 --- /dev/null +++ b/frontend/pweb/lib/widgets/logo.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + + +class ServiceLogo extends StatelessWidget { + final double size; + + const ServiceLogo({ super.key, this.size = 48 }); + + @override + Widget build(BuildContext context) => SizedBox( + height: size, + width: size, + child: Image.asset('resources/logo.png'), + ); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/password/hint/error.dart b/frontend/pweb/lib/widgets/password/hint/error.dart new file mode 100644 index 0000000..25c7284 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/hint/error.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/password/hint/widget.dart'; + + +class PasswordValidationErrorLabel extends StatelessWidget { + final String labelText; + const PasswordValidationErrorLabel({super.key, required this.labelText}); + + @override + Widget build(BuildContext context) { + return PasswordValidationOutput( + children: [ + Text( + labelText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ) + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/password/hint/full.dart b/frontend/pweb/lib/widgets/password/hint/full.dart new file mode 100644 index 0000000..232f2f7 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/hint/full.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'package:fancy_password_field/fancy_password_field.dart'; + +import 'package:pweb/widgets/password/hint/validation_result.dart'; +import 'package:pweb/widgets/password/hint/widget.dart'; + + +Widget expandedValidation(BuildContext context, Set rules, String value) { + return PasswordValidationOutput( + children: rules.map( + (rule) => PasswordValidationResult( + ruleName: rule.name, + result: rule.validate(value), + ), + ).toList() + ); +} diff --git a/frontend/pweb/lib/widgets/password/hint/short.dart b/frontend/pweb/lib/widgets/password/hint/short.dart new file mode 100644 index 0000000..43273b3 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/hint/short.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:fancy_password_field/fancy_password_field.dart'; + +import 'package:pweb/widgets/password/hint/error.dart'; +import 'package:pweb/widgets/password/hint/widget.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +Widget shortValidation(BuildContext context, Set rules, String value) { + if (value.isEmpty) return Container(); + final failedRules = rules.where((rule) => !rule.validate(value)); + return (failedRules.isNotEmpty) + ? PasswordValidationOutput( + children: [ + PasswordValidationErrorLabel( + labelText: AppLocalizations.of(context)!.passwordValidationError( + rules.firstWhere((rule) => !rule.validate(value)).name + ), + ), + ], + ) + : Container(); +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/password/hint/validation_result.dart b/frontend/pweb/lib/widgets/password/hint/validation_result.dart new file mode 100644 index 0000000..ee967bf --- /dev/null +++ b/frontend/pweb/lib/widgets/password/hint/validation_result.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + + +class PasswordValidationResult extends StatelessWidget { + final String ruleName; + final bool result; + + const PasswordValidationResult({ + super.key, + required this.ruleName, + required this.result + }); + + Color _selectColor(BuildContext context, bool res) { + final scheme = Theme.of(context).colorScheme; + return res ? scheme.secondary : scheme.error; + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + result ? Icons.check : Icons.close, + color: _selectColor(context, result), + ), + const SizedBox(width: 8), + Text( + ruleName, + style: TextStyle(color: _selectColor(context, result)), + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/password/hint/widget.dart b/frontend/pweb/lib/widgets/password/hint/widget.dart new file mode 100644 index 0000000..473f570 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/hint/widget.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/vspacer.dart'; + + +class PasswordValidationOutput extends StatelessWidget { + final List children; + + const PasswordValidationOutput({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + VSpacer(multiplier: 0.25), + ListView( + shrinkWrap: true, + children: children, + ) + ] + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/password/password.dart b/frontend/pweb/lib/widgets/password/password.dart new file mode 100644 index 0000000..318e957 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/password.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import 'package:fancy_password_field/fancy_password_field.dart'; + +import 'package:pweb/config/constants.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PasswordField extends StatefulWidget { + final TextEditingController controller; + final ValueChanged? onValid; + final bool hasStrengthIndicator; + final String? labelText; + final Set rules; + final AutovalidateMode? autovalidateMode; + final Widget Function(Set, String)? validationRuleBuilder; + + const PasswordField({ + super.key, + required this.controller, + this.onValid, + this.validationRuleBuilder, + this.labelText, + this.hasStrengthIndicator = false, + this.autovalidateMode, + this.rules = const {}, + }); + + @override + State createState() => _PasswordFieldState(); +} + +class _PasswordFieldState extends State { + bool _lastValidationResult = false; + + void _onChanged(String value) { + bool isValid = widget.rules.every((rule) => rule.validate(value)); + + // Only trigger onValid if validation result has changed + if (isValid != _lastValidationResult) { + _lastValidationResult = isValid; + widget.onValid?.call(isValid); + } + } + + @override + Widget build(BuildContext context) { + return FancyPasswordField( + key: widget.key, + controller: widget.controller, + decoration: InputDecoration( + labelText: widget.labelText ?? AppLocalizations.of(context)!.password, + ), + validationRules: widget.rules, + hasStrengthIndicator: widget.hasStrengthIndicator, + validationRuleBuilder: widget.validationRuleBuilder, + autovalidateMode: widget.autovalidateMode, + onChanged: _onChanged, + ); + } +} + +Widget defaulRulesPasswordField( + BuildContext context, { + required TextEditingController controller, + Key? key, + ValueChanged? onValid, + Widget Function(Set, String)? validationRuleBuilder, + String? labelText, + FocusNode? focusNode, + AutovalidateMode? autovalidateMode, + bool hasStrengthIndicator = false, + Set additionalRules = const {}, +}) { + Set rules = { + DigitValidationRule( + customText: AppLocalizations.of(context)!.passwordValidationRuleDigit, + ), + UppercaseValidationRule( + customText: AppLocalizations.of(context)!.passwordValidationRuleUpperCase, + ), + LowercaseValidationRule( + customText: AppLocalizations.of(context)!.passwordValidationRuleLowerCase, + ), + MinCharactersValidationRule( + Constants.minPasswordCharacters, + customText: AppLocalizations.of(context)! + .passwordValidationRuleMinCharacters(Constants.minPasswordCharacters), + ), + ...additionalRules, + }; + + return PasswordField( + key: key, + controller: controller, + onValid: onValid, + validationRuleBuilder: validationRuleBuilder, + hasStrengthIndicator: hasStrengthIndicator, + labelText: labelText, + autovalidateMode: autovalidateMode, + rules: rules, + ); +} diff --git a/frontend/pweb/lib/widgets/password/verify.dart b/frontend/pweb/lib/widgets/password/verify.dart new file mode 100644 index 0000000..8507821 --- /dev/null +++ b/frontend/pweb/lib/widgets/password/verify.dart @@ -0,0 +1,95 @@ +import 'package:flutter/widgets.dart'; + +import 'package:fancy_password_field/fancy_password_field.dart'; + +import 'package:pweb/widgets/password/hint/error.dart'; +import 'package:pweb/widgets/password/hint/short.dart'; +import 'package:pweb/widgets/password/password.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PasswordVeirificationRule extends ValidationRule { + final String ruleName; + final TextEditingController externalPasswordController; + + PasswordVeirificationRule({ + required this.ruleName, + required this.externalPasswordController, + }); + + @override + String get name => ruleName; + + @override + bool get showName => true; + + @override + bool validate(String value) => value == externalPasswordController.text; +} + +class VerifyPasswordField extends StatefulWidget { + final ValueChanged? onValid; + final TextEditingController controller; + final TextEditingController externalPasswordController; + + const VerifyPasswordField({ + super.key, + this.onValid, + required this.controller, + required this.externalPasswordController, + }); + + @override + State createState() => _VerifyPasswordFieldState(); +} + +class _VerifyPasswordFieldState extends State { + bool _isCurrentlyValid = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_validatePassword); + widget.externalPasswordController.addListener(_validatePassword); + } + + void _validatePassword() { + final isValid = widget.controller.text == widget.externalPasswordController.text; + + // Only call onValid if the validity state has changed to prevent infinite loops + if (isValid != _isCurrentlyValid) { + setState(() { + _isCurrentlyValid = isValid; + }); + widget.onValid?.call(isValid); + } + } + + @override + Widget build(BuildContext context) { + final rule = PasswordVeirificationRule( + ruleName: AppLocalizations.of(context)!.passwordsDoNotMatch, + externalPasswordController: widget.externalPasswordController, + ); + + return defaulRulesPasswordField( + context, + controller: widget.controller, + key: widget.key, + labelText: AppLocalizations.of(context)!.confirmPassword, + additionalRules: { rule }, + validationRuleBuilder: (rules, value) => rule.validate(value) + ? shortValidation(context, rules, value) + : PasswordValidationErrorLabel(labelText: AppLocalizations.of(context)!.passwordsDoNotMatch), + onValid: widget.onValid, + ); + } + + @override + void dispose() { + widget.controller.removeListener(_validatePassword); + widget.externalPasswordController.removeListener(_validatePassword); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/widgets/protected/widget.dart b/frontend/pweb/lib/widgets/protected/widget.dart new file mode 100644 index 0000000..49ce816 --- /dev/null +++ b/frontend/pweb/lib/widgets/protected/widget.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/resources.dart'; +import 'package:pshared/models/permissions/action.dart' as perm; +import 'package:pshared/provider/permissions.dart'; + + +T? protectedWidgetctx(BuildContext context, ResourceType resource, T child, {perm.Action? action}) { + return protectedWidget(Provider.of(context, listen: false), resource, child, action: action); +} + +T? protectedWidget(PermissionsProvider provider, ResourceType resource, T child, {perm.Action? action}) { + return provider.canAccessResource(resource, action: action) ? child : null; +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/search.dart b/frontend/pweb/lib/widgets/search.dart new file mode 100644 index 0000000..d65a945 --- /dev/null +++ b/frontend/pweb/lib/widgets/search.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + + +class SearchBox extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged onChanged; + final VoidCallback? onClear; + final String? Function(String?)? validator; + final String? labelText; + final String? helperText; + + const SearchBox({ + super.key, + required this.controller, + required this.hintText, + required this.onChanged, + this.onClear, + this.validator, + this.labelText, + this.helperText, + }); + + @override + Widget build(BuildContext context) => TextFormField( + controller: controller, + onChanged: onChanged, + validator: validator, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) => value.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + controller.clear(); + if (onClear != null) { + onClear!(); + } + onChanged(''); + }, + ) + : const SizedBox.shrink(), + ), + hintText: hintText, + labelText: labelText, + helperText: helperText, + border: const UnderlineInputBorder(), + ), + ); +} diff --git a/frontend/pweb/lib/widgets/sidebar/destinations.dart b/frontend/pweb/lib/widgets/sidebar/destinations.dart new file mode 100644 index 0000000..62598c9 --- /dev/null +++ b/frontend/pweb/lib/widgets/sidebar/destinations.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +enum PayoutDestination { + dashboard(Icons.dashboard_outlined, 'dashboard'), + sendPayout(Icons.send_outlined, 'sendPayout'), + recipients(Icons.people_outline, 'recipients'), + reports(Icons.insert_chart, 'reports'), + settings(Icons.settings_outlined, 'settings'), + methods(Icons.credit_card, 'methods'), + payment(Icons.payment, 'payout'), + addrecipient(Icons.app_registration, 'add recipient'), + editwallet(Icons.wallet, 'edit wallet'); + + + const PayoutDestination(this.icon, this.labelKey); + + final IconData icon; + final String labelKey; + + String localizedLabel(BuildContext context) { + final loc = AppLocalizations.of(context)!; + switch (this) { + case PayoutDestination.dashboard: + return loc.payoutNavDashboard; + case PayoutDestination.sendPayout: + return loc.payoutNavSendPayout; + case PayoutDestination.recipients: + return loc.payoutNavRecipients; + case PayoutDestination.reports: + return loc.payoutNavReports; + case PayoutDestination.settings: + return loc.payoutNavSettings; + case PayoutDestination.methods: + return loc.payoutNavMethods; + case PayoutDestination.payment: + return loc.payout; + case PayoutDestination.addrecipient: + return loc.addRecipient; + case PayoutDestination.editwallet: + return 'Edit Wallet'; + } + } +} diff --git a/frontend/pweb/lib/widgets/sidebar/page.dart b/frontend/pweb/lib/widgets/sidebar/page.dart new file mode 100644 index 0000000..68c8d99 --- /dev/null +++ b/frontend/pweb/lib/widgets/sidebar/page.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/pages/address_book/form/page.dart'; +import 'package:pweb/pages/address_book/page/page.dart'; +import 'package:pweb/pages/payment_methods/page.dart'; +import 'package:pweb/pages/payment_page/page.dart'; +import 'package:pweb/pages/payment_page/wallet/edit/page.dart'; +import 'package:pweb/pages/report/page.dart'; +import 'package:pweb/pages/settings/profile/page.dart'; +import 'package:pweb/providers/page_selector.dart'; +import 'package:pweb/widgets/appbar/app_bar.dart'; +import 'package:pweb/pages/dashboard/dashboard.dart'; +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/widgets/sidebar/sidebar.dart'; + + +class PageSelector extends StatelessWidget { + const PageSelector({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + Widget content; + switch (provider.selected) { + case PayoutDestination.dashboard: + content = DashboardPage( + onRecipientSelected: (recipient) => + provider.selectRecipient(recipient), + onGoToPaymentWithoutRecipient: provider.startPaymentWithoutRecipient, + ); + break; + + case PayoutDestination.recipients: + content = RecipientAddressBookPage( + onRecipientSelected: (recipient) => + provider.selectRecipient(recipient, fromList: true), + onAddRecipient: provider.goToAddRecipient, + onEditRecipient: provider.editRecipient, + ); + break; + + case PayoutDestination.addrecipient: + final recipient = provider.recipientProvider?.selectedRecipient; + content = AdressBookRecipientForm( + recipient: recipient, + onSaved: (_) => provider.selectPage(PayoutDestination.recipients), + ); + break; + + case PayoutDestination.payment: + content = PaymentPage( + type: provider.type, + onBack: (_) => provider.goBackFromPayment(), + ); + break; + + case PayoutDestination.settings: + content = ProfileSettingsPage(); + break; + + case PayoutDestination.reports: + content = OperationHistoryPage(); + break; + + case PayoutDestination.methods: + content = PaymentConfigPage( + onWalletTap: provider.selectWallet, + ); + break; + + case PayoutDestination.editwallet: + final wallet = provider.walletsProvider?.selectedWallet; + content = wallet != null + ? WalletEditPage( + wallet: wallet, + onBack: () => provider.goBackFromPayment(), + ) + : const Center(child: Text('No wallet selected')); //TODO Localize + break; + + default: + content = Text(provider.selected.name); + } + + return Scaffold( + appBar: PayoutAppBar( + title: Text(provider.selected.localizedLabel(context)), + onAddFundsPressed: () {}, + onLogout: () => debugPrint('Logout clicked'), + ), + body: Padding( + padding: const EdgeInsets.only(left: 200, top: 40, right: 200), + child: Row( + spacing: 40, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PayoutSidebar( + selected: provider.selected, + onSelected: provider.selectPage, + onLogout: () => debugPrint('Logout clicked'), + ), + Expanded(child: content), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/sidebar/side_menu.dart b/frontend/pweb/lib/widgets/sidebar/side_menu.dart new file mode 100644 index 0000000..494ffdb --- /dev/null +++ b/frontend/pweb/lib/widgets/sidebar/side_menu.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:pweb/services/amplitude.dart'; + +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +class SideMenuColumn extends StatelessWidget { + final ThemeData theme; + final String? avatarUrl; + final String? userName; + final List items; + final PayoutDestination selected; + final void Function(PayoutDestination) onSelected; + + const SideMenuColumn({ + super.key, + required this.theme, + required this.avatarUrl, + required this.userName, + required this.items, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.onSecondary, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + children: items.map((item) { + final isSelected = item == selected; + final backgroundColor = isSelected + ? theme.colorScheme.primaryContainer + : Colors.transparent; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: () { + onSelected(item); + AmplitudeService.pageOpened(item, uiSource: 'sidebar'); + }, + borderRadius: BorderRadius.circular(12), + hoverColor: theme.colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 12), + child: Row( + children: [ + Icon(item.icon, color: theme.iconTheme.color, size: 28), + const SizedBox(width: 16), + Text( + item.localizedLabel(context), + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/sidebar/sidebar.dart b/frontend/pweb/lib/widgets/sidebar/sidebar.dart new file mode 100644 index 0000000..d3dac81 --- /dev/null +++ b/frontend/pweb/lib/widgets/sidebar/sidebar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/widgets/sidebar/side_menu.dart'; +import 'package:pweb/widgets/sidebar/user.dart'; + + +class PayoutSidebar extends StatelessWidget { + const PayoutSidebar({ + super.key, + required this.selected, + required this.onSelected, + this.onLogout, + this.userName, + this.avatarUrl, + }); + + final PayoutDestination selected; + final ValueChanged onSelected; + final VoidCallback? onLogout; + + final String? userName; + final String? avatarUrl; + + + @override + Widget build(BuildContext context) { + final items = [ + PayoutDestination.dashboard, + PayoutDestination.recipients, + PayoutDestination.methods, + PayoutDestination.reports, + ]; + + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + UserProfileCard( + theme: theme, + avatarUrl: avatarUrl, + userName: userName, + selected: selected, + onSelected: onSelected + ), + const SizedBox(height: 8), + SideMenuColumn( + theme: theme, + avatarUrl: avatarUrl, + userName: userName, + items: items, + selected: selected, + onSelected: onSelected, + ), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/sidebar/user.dart b/frontend/pweb/lib/widgets/sidebar/user.dart new file mode 100644 index 0000000..a979614 --- /dev/null +++ b/frontend/pweb/lib/widgets/sidebar/user.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/sidebar/destinations.dart'; + + +class UserProfileCard extends StatelessWidget { + final ThemeData theme; + final String? avatarUrl; + final String? userName; + final PayoutDestination selected; + final void Function(PayoutDestination) onSelected; + + const UserProfileCard({ + super.key, + required this.theme, + required this.avatarUrl, + required this.userName, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + bool isSelected = selected == PayoutDestination.settings; + final backgroundColor = isSelected + ? theme.colorScheme.primaryContainer + : Colors.transparent; + + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(14), + color: theme.colorScheme.onSecondary, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => onSelected(PayoutDestination.settings), + child: Container( + height: 80, + width: 320, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.only(top: 15.0, left: 30, right: 20, bottom: 15), + child: Row( + spacing: 5, + children: [ + CircleAvatar( + radius: 20, + foregroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, + child: avatarUrl == null ? const Icon(Icons.person, size: 28) : null, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + userName ?? 'User Name', + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/stats/card.dart b/frontend/pweb/lib/widgets/stats/card.dart new file mode 100644 index 0000000..50f7daf --- /dev/null +++ b/frontend/pweb/lib/widgets/stats/card.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/vspacer.dart'; + + +class StatCard extends StatelessWidget { + final IconData icon; + final String text; + final int count; + final Color color; + + const StatCard({ + super.key, + required this.icon, + required this.text, + required this.count, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Expanded( + child: Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Icon(icon, size: 24, color: color), + const VSpacer(multiplier: 0.25), + Text( + '$count', + style: theme.textTheme.titleMedium, + ), + Text( + text, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/widgets/text_field.dart b/frontend/pweb/lib/widgets/text_field.dart new file mode 100644 index 0000000..5cdd542 --- /dev/null +++ b/frontend/pweb/lib/widgets/text_field.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + + +class NotEmptyTextFormField extends StatelessWidget { + final String labelText; + final String error; + final TextEditingController controller; + final ValueChanged? onValid; + final String? hintText; + final bool readOnly; + + const NotEmptyTextFormField({ + super.key, + required this.controller, + required this.labelText, + required this.error, + this.onValid, + this.hintText, + required this.readOnly, + }); + + bool _validate(String? value) { + return !(value == null || value.isNotEmpty); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration(labelText: labelText, hintText: hintText), + validator: (value) => _validate(value) ? error : null, + onChanged: (value) { + if (onValid != null) onValid!(_validate(value)); + }, + readOnly: readOnly, + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/widgets/username.dart b/frontend/pweb/lib/widgets/username.dart new file mode 100644 index 0000000..e9452c8 --- /dev/null +++ b/frontend/pweb/lib/widgets/username.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class UsernameField extends StatelessWidget { + final TextEditingController controller; + final ValueChanged? onValid; + + const UsernameField({ + super.key, + required this.controller, + this.onValid, + }); + + String? _reportResult(String? msg) { + onValid?.call(msg == null); + return msg; + } + + @override + Widget build(BuildContext context) => TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.username, + hintText: AppLocalizations.of(context)!.usernameHint, + ), + validator: (value) { + return _reportResult((value?.isNotEmpty ?? false) ? null : AppLocalizations.of(context)!.usernameErrorInvalid); + // bool isValid = value != null && EmailValidator.validate(value); + // if (!isValid) { + // return _reportResult(AppLocalizations.of(context)!.usernameErrorInvalid); + // } + // final tld = value.split('.').last; + // isValid = tlds.contains(tld); + // if (!isValid) { + // return _reportResult(AppLocalizations.of(context)!.usernameUnknownTLD(tld)); + // } + // return _reportResult(null); + }, + onChanged: (value) => onValid?.call(value.isNotEmpty), + ); +} diff --git a/frontend/pweb/lib/widgets/vspacer.dart b/frontend/pweb/lib/widgets/vspacer.dart new file mode 100644 index 0000000..99db732 --- /dev/null +++ b/frontend/pweb/lib/widgets/vspacer.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + + +class VSpacer extends StatelessWidget{ + final double spacing; + final double multiplier; + + const VSpacer({super.key, this.spacing = 16, this.multiplier = 1.0}); + + @override + Widget build(BuildContext context) { + return SizedBox(height: spacing * multiplier); + } +} diff --git a/frontend/pweb/linux/.gitignore b/frontend/pweb/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/frontend/pweb/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/frontend/pweb/linux/CMakeLists.txt b/frontend/pweb/linux/CMakeLists.txt new file mode 100644 index 0000000..d447c09 --- /dev/null +++ b/frontend/pweb/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "web") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.web") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/frontend/pweb/linux/flutter/CMakeLists.txt b/frontend/pweb/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/frontend/pweb/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/frontend/pweb/linux/flutter/generated_plugin_registrant.cc b/frontend/pweb/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..13807bb --- /dev/null +++ b/frontend/pweb/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/frontend/pweb/linux/flutter/generated_plugin_registrant.h b/frontend/pweb/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/frontend/pweb/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/pweb/linux/flutter/generated_plugins.cmake b/frontend/pweb/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b01d1fd --- /dev/null +++ b/frontend/pweb/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_timezone + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/frontend/pweb/linux/runner/CMakeLists.txt b/frontend/pweb/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/frontend/pweb/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/frontend/pweb/linux/runner/main.cc b/frontend/pweb/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/frontend/pweb/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/frontend/pweb/linux/runner/my_application.cc b/frontend/pweb/linux/runner/my_application.cc new file mode 100644 index 0000000..dcd3802 --- /dev/null +++ b/frontend/pweb/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "web"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "web"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/frontend/pweb/linux/runner/my_application.h b/frontend/pweb/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/frontend/pweb/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/frontend/pweb/macos/.gitignore b/frontend/pweb/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/frontend/pweb/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/frontend/pweb/macos/Flutter/Flutter-Debug.xcconfig b/frontend/pweb/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/frontend/pweb/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/pweb/macos/Flutter/Flutter-Release.xcconfig b/frontend/pweb/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/frontend/pweb/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..79f5652 --- /dev/null +++ b/frontend/pweb/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import amplitude_flutter +import file_selector_macos +import flutter_timezone +import path_provider_foundation +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AmplitudeFlutterPlugin.register(with: registry.registrar(forPlugin: "AmplitudeFlutterPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/frontend/pweb/macos/Podfile b/frontend/pweb/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/frontend/pweb/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/frontend/pweb/macos/Runner.xcodeproj/project.pbxproj b/frontend/pweb/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a1153bb --- /dev/null +++ b/frontend/pweb/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* web.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "web.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* web.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* web.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.web.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/frontend/pweb/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/pweb/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/pweb/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/pweb/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/pweb/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..13ff3eb --- /dev/null +++ b/frontend/pweb/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/pweb/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/frontend/pweb/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/pweb/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/pweb/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/frontend/pweb/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/frontend/pweb/macos/Runner/AppDelegate.swift b/frontend/pweb/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/frontend/pweb/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/frontend/pweb/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/frontend/pweb/macos/Runner/Base.lproj/MainMenu.xib b/frontend/pweb/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/frontend/pweb/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/pweb/macos/Runner/Configs/AppInfo.xcconfig b/frontend/pweb/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..721d293 --- /dev/null +++ b/frontend/pweb/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = web + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.web + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/frontend/pweb/macos/Runner/Configs/Debug.xcconfig b/frontend/pweb/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/frontend/pweb/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/pweb/macos/Runner/Configs/Release.xcconfig b/frontend/pweb/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/frontend/pweb/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/frontend/pweb/macos/Runner/Configs/Warnings.xcconfig b/frontend/pweb/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/frontend/pweb/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/frontend/pweb/macos/Runner/DebugProfile.entitlements b/frontend/pweb/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/frontend/pweb/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/frontend/pweb/macos/Runner/Info.plist b/frontend/pweb/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/frontend/pweb/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/frontend/pweb/macos/Runner/MainFlutterWindow.swift b/frontend/pweb/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/frontend/pweb/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/frontend/pweb/macos/Runner/Release.entitlements b/frontend/pweb/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/frontend/pweb/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/frontend/pweb/macos/RunnerTests/RunnerTests.swift b/frontend/pweb/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/frontend/pweb/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/frontend/pweb/pubspec.lock b/frontend/pweb/pubspec.lock new file mode 100644 index 0000000..11a25c7 --- /dev/null +++ b/frontend/pweb/pubspec.lock @@ -0,0 +1,1374 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" + source: hosted + version: "88.0.0" + amplitude_flutter: + dependency: "direct main" + description: + name: amplitude_flutter + sha256: af506e2e326251be89eee9bef5a86b10e2b442c3e52e1305e39a67e55d6d0f74 + url: "https://pub.dev" + source: hosted + version: "4.3.7" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + appflowy_board: + dependency: "direct main" + description: + name: appflowy_board + sha256: "4dc5ce013913723ca330db350df154abdf1315285bcf61a35d65471e9ea00517" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + badges: + dependency: "direct main" + description: + name: badges + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + bech32: + dependency: transitive + description: + name: bech32 + sha256: "156cbace936f7720c79a79d16a03efad343b1ef17106716e04b8b8e39f99f7f7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "6439a9c71a4e6eca8d9490c1b380a25b02675aa688137dfbe66d2062884a23ac" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "2b21a125d66a86b9511cc3fb6c668c42e9a1185083922bf60e46d483a81a9712" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: fd3c09f4bbff7fa6e8d8ef688a0b2e8a6384e6483a25af0dac75fef362bcfe6f + url: "https://pub.dev" + source: hosted + version: "2.7.0" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: ab27e46c8aa233e610cf6084ee6d8a22c6f873a0a9929241d8855b7a72978ae7 + url: "https://pub.dev" + source: hosted + version: "9.3.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: ba95c961bafcd8686d1cf63be864eb59447e795e124d98d6a27d91fcd13602fb + url: "https://pub.dev" + source: hosted + version: "8.11.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + country_flags: + dependency: "direct main" + description: + name: country_flags + sha256: "78a7bf8aabd7ae1a90087f0c517471ac9ebfe07addc652692f58da0f0f833196" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + sha256: "99b091ec6891ba0c5331fdc2b502993c7c108f898995739a73c6845d71dad70c" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + email_validator: + dependency: "direct main" + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fancy_password_field: + dependency: "direct main" + description: + name: fancy_password_field + sha256: ff2bd9daecfc09d00c978657642774d11320020678e589bdb5469b5079385448 + url: "https://pub.dev" + source: hosted + version: "2.0.8" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_multi_formatter: + dependency: "direct main" + description: + name: flutter_multi_formatter + sha256: "29d9b3d30a985f5a9c3dd52b4e25e64b9a20ebdcf4d9fed0c71e653406598604" + url: "https://pub.dev" + source: hosted + version: "2.13.10" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.dev" + source: hosted + version: "2.0.30" + flutter_settings_ui: + dependency: "direct main" + description: + name: flutter_settings_ui + sha256: dcc506fab724192594e5c232b6214a941abd6e7b5151626635b89258fadbc17c + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + font_awesome_flutter: + dependency: transitive + description: + name: font_awesome_flutter + sha256: "27af5982e6c510dec1ba038eff634fa284676ee84e3fd807225c80c4ad869177" + url: "https://pub.dev" + source: hosted + version: "10.10.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: ced3fdc143c1437234ac3b8e985f3286cf138968bb83ca9a6f94d22f2951c6b9 + url: "https://pub.dev" + source: hosted + version: "16.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + highlight: + dependency: transitive + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + icann_tlds: + dependency: "direct main" + description: + name: icann_tlds + sha256: "399432af3f1882780bfe57ade60657367f244cb74efd91cd426ffda6644e967e" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jovial_misc: + dependency: transitive + description: + name: jovial_misc + sha256: "4301011027d87b8b919cb862db84071a34448eadbb32cc8d40fe505424dfe69a" + url: "https://pub.dev" + source: hosted + version: "0.9.2" + jovial_svg: + dependency: "direct main" + description: + name: jovial_svg + sha256: "6791b1435547bdc0793081a166d41a8a313ebc61e4e5136fb7a3218781fb9e50" + url: "https://pub.dev" + source: hosted + version: "1.1.27" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + url: "https://pub.dev" + source: hosted + version: "11.0.1" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c + url: "https://pub.dev" + source: hosted + version: "2.3.2+8" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + password_strength: + dependency: transitive + description: + name: password_strength + sha256: "0e51e3d864e37873a1347e658147f88b66e141ee36c58e19828dc5637961e1ce" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + pin_code_fields: + dependency: "direct main" + description: + name: pin_code_fields + sha256: "4c0db7fbc889e622e7c71ea54b9ee624bb70c7365b532abea0271b17ea75b729" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pshared: + dependency: "direct main" + description: + path: "../pshared" + relative: true + source: path + version: "1.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 + url: "https://pub.dev" + source: hosted + version: "11.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + url: "https://pub.dev" + source: hosted + version: "2.4.12" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + syncfusion_flutter_charts: + dependency: "direct main" + description: + name: syncfusion_flutter_charts + sha256: "68fdb029dad34a46e4c9cfad8ad66fe29db7b303bd96849261ab2b23a168d0e8" + url: "https://pub.dev" + source: hosted + version: "30.2.7" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: bfd026c0f9822b49ff26fed11cd3334519acb6a6ad4b0c81d9cd18df6af1c4c0 + url: "https://pub.dev" + source: hosted + version: "30.2.7" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" + url: "https://pub.dev" + source: hosted + version: "6.3.18" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + web: + dependency: "direct main" + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml new file mode 100644 index 0000000..b8bc11c --- /dev/null +++ b/frontend/pweb/pubspec.yaml @@ -0,0 +1,140 @@ +name: pweb +description: "Profee Pay B2B Web Client" +# 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: 3.2.936+14 + +environment: + sdk: ^3.8.1 + +# 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: + amplitude_flutter: ^4.0.1 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + pshared: + path: ../pshared + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + intl: ^0.20.2 + provider: ^6.1.5 + shared_preferences: ^2.2.3 + flutter_launcher_icons: ^0.14.0 + country_flags: ^3.0.0 + logging: ^1.2.0 + email_validator: ^3.0.0 + fancy_password_field: ^2.0.7 + web: ^1.1.0 + share_plus: ^11.0.0 + collection: ^1.18.0 + icann_tlds: ^1.0.0 + flutter_timezone: ^4.0.0 + json_annotation: ^4.9.0 + go_router: ^16.0.0 + jovial_svg: ^1.1.23 + cached_network_image: ^3.4.1 + image_picker: ^1.1.2 + appflowy_board: ^0.1.2 + badges: ^3.1.2 + markdown_widget: ^2.3.2+6 + timeago: ^3.7.0 + flutter_settings_ui: ^3.0.1 + pin_code_fields: ^8.0.1 + fl_chart: ^1.0.0 + syncfusion_flutter_charts: ^30.1.40 + flutter_multi_formatter: ^2.13.7 + dotted_border: ^3.1.0 + + + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.11 + + # 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 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + + # 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: + - resources/logo.png + - resources/logo.si + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package + +flutter_intl: + enabled: true + localizations_delegates: + - flutter_localizations + - app_localizations + +flutter_launcher_icons: + image_path: "resources/logo.png" + android: true + ios: true + web: + generate: true + image_path: "resources/logo.png" \ No newline at end of file diff --git a/frontend/pweb/resources/logo.png b/frontend/pweb/resources/logo.png new file mode 100644 index 0000000..7172675 Binary files /dev/null and b/frontend/pweb/resources/logo.png differ diff --git a/frontend/pweb/resources/logo.si b/frontend/pweb/resources/logo.si new file mode 100644 index 0000000..4d4dedb Binary files /dev/null and b/frontend/pweb/resources/logo.si differ diff --git a/frontend/pweb/test/widget_test.dart b/frontend/pweb/test/widget_test.dart new file mode 100644 index 0000000..045c861 --- /dev/null +++ b/frontend/pweb/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pweb/app/app.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const PayApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/frontend/pweb/untranslated.txt b/frontend/pweb/untranslated.txt new file mode 100644 index 0000000..5039bda --- /dev/null +++ b/frontend/pweb/untranslated.txt @@ -0,0 +1,11 @@ +{ + "ru": [ + "errorAccountExists", + "companyName", + "companynameRequired", + "errorSignUp", + "companyDescription", + "companyDescriptionHint", + "optional" + ] +} diff --git a/frontend/pweb/web/favicon.png b/frontend/pweb/web/favicon.png new file mode 100644 index 0000000..71d8f53 Binary files /dev/null and b/frontend/pweb/web/favicon.png differ diff --git a/frontend/pweb/web/icons/Icon-192.png b/frontend/pweb/web/icons/Icon-192.png new file mode 100644 index 0000000..9d7cfe6 Binary files /dev/null and b/frontend/pweb/web/icons/Icon-192.png differ diff --git a/frontend/pweb/web/icons/Icon-512.png b/frontend/pweb/web/icons/Icon-512.png new file mode 100644 index 0000000..1614686 Binary files /dev/null and b/frontend/pweb/web/icons/Icon-512.png differ diff --git a/frontend/pweb/web/icons/Icon-maskable-192.png b/frontend/pweb/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..9d7cfe6 Binary files /dev/null and b/frontend/pweb/web/icons/Icon-maskable-192.png differ diff --git a/frontend/pweb/web/icons/Icon-maskable-512.png b/frontend/pweb/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..1614686 Binary files /dev/null and b/frontend/pweb/web/icons/Icon-maskable-512.png differ diff --git a/frontend/pweb/web/index.html b/frontend/pweb/web/index.html new file mode 100644 index 0000000..475a0a7 --- /dev/null +++ b/frontend/pweb/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + web + + + + + + diff --git a/frontend/pweb/web/manifest.json b/frontend/pweb/web/manifest.json new file mode 100644 index 0000000..e5b4f87 --- /dev/null +++ b/frontend/pweb/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "web", + "short_name": "web", + "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" + } + ] +} \ No newline at end of file diff --git a/frontend/pweb/windows/.gitignore b/frontend/pweb/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/frontend/pweb/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/frontend/pweb/windows/CMakeLists.txt b/frontend/pweb/windows/CMakeLists.txt new file mode 100644 index 0000000..0547238 --- /dev/null +++ b/frontend/pweb/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(web LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "web") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/frontend/pweb/windows/flutter/CMakeLists.txt b/frontend/pweb/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/frontend/pweb/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/frontend/pweb/windows/flutter/generated_plugin_registrant.cc b/frontend/pweb/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..a6cdd48 --- /dev/null +++ b/frontend/pweb/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/frontend/pweb/windows/flutter/generated_plugin_registrant.h b/frontend/pweb/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/frontend/pweb/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/pweb/windows/flutter/generated_plugins.cmake b/frontend/pweb/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..42d0efb --- /dev/null +++ b/frontend/pweb/windows/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + flutter_timezone + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/frontend/pweb/windows/runner/CMakeLists.txt b/frontend/pweb/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/frontend/pweb/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/pweb/windows/runner/Runner.rc b/frontend/pweb/windows/runner/Runner.rc new file mode 100644 index 0000000..7548fd9 --- /dev/null +++ b/frontend/pweb/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "web" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "web" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "web.exe" "\0" + VALUE "ProductName", "web" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/frontend/pweb/windows/runner/flutter_window.cpp b/frontend/pweb/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/frontend/pweb/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/frontend/pweb/windows/runner/flutter_window.h b/frontend/pweb/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/frontend/pweb/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/frontend/pweb/windows/runner/main.cpp b/frontend/pweb/windows/runner/main.cpp new file mode 100644 index 0000000..0988e65 --- /dev/null +++ b/frontend/pweb/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"web", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/frontend/pweb/windows/runner/resource.h b/frontend/pweb/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/frontend/pweb/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/frontend/pweb/windows/runner/resources/app_icon.ico b/frontend/pweb/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/frontend/pweb/windows/runner/resources/app_icon.ico differ diff --git a/frontend/pweb/windows/runner/runner.exe.manifest b/frontend/pweb/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/frontend/pweb/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/frontend/pweb/windows/runner/utils.cpp b/frontend/pweb/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/frontend/pweb/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/frontend/pweb/windows/runner/utils.h b/frontend/pweb/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/frontend/pweb/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/frontend/pweb/windows/runner/win32_window.cpp b/frontend/pweb/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/frontend/pweb/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/frontend/pweb/windows/runner/win32_window.h b/frontend/pweb/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/frontend/pweb/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_