Frontend first draft

This commit is contained in:
Arseni
2025-11-13 15:06:15 +03:00
parent e47f343afb
commit ddb54ddfdc
504 changed files with 25498 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pshared/utils/snackbar.dart';
Future<void> 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;
}

View File

@@ -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;
}
}

View File

@@ -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,
});
}

View File

@@ -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),
],
);
}

View File

@@ -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<String, String> 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<String, String> 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 errors string representation.
static String handleError(BuildContext context, Object e) {
return handleErrorLocs(AppLocalizations.of(context)!, e);
}
static String handleErrorLocs(AppLocalizations locs, Object e) {
final errorHandlers = <Type, String Function(Object)>{
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);
}
}

View File

@@ -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<SnackBar, SnackBarClosedReason> 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<SnackBar, SnackBarClosedReason> 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<T?> executeActionWithNotification<T>({
required BuildContext context,
required Future<T> 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<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUserOfError({
required ScaffoldMessengerState scaffoldMessenger,
required String errorSituation,
required Object exception,
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
WidgetsBinding.instance.addPostFrameCallback((_) => completer.complete(notifyUserOfErrorX(
scaffoldMessenger: scaffoldMessenger,
errorSituation: errorSituation,
exception: exception,
appLocalizations: appLocalizations,
delaySeconds: delaySeconds,
)),
);
return completer.future;
}
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> 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),
)),
),
);

View File

@@ -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<String, String> 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<String, String> 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 errors string representation.
static String handleError(BuildContext context, Object e) {
return handleErrorLocs(AppLocalizations.of(context)!, e);
}
static String handleErrorLocs(AppLocalizations locs, Object e) {
final errorHandlers = <Type, String Function(Object)>{
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);
}
}

View File

@@ -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<String, String> 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),
);
}

View File

@@ -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];
}

View File

@@ -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();
}

View File

@@ -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<void> invokeAndNotify<T>(
BuildContext context, {
required Future<T> 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;
}
}

View File

@@ -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<PaymentMethod> methods;
final ValueChanged<PaymentMethod> onChanged;
final PaymentMethod? initialValue;
const PaymentMethodDropdown({
super.key,
required this.methods,
required this.onChanged,
this.initialValue,
});
@override
State<PaymentMethodDropdown> createState() => _PaymentMethodDropdownState();
}
class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
late PaymentMethod _selectedMethod;
@override
void initState() {
super.initState();
_selectedMethod = widget.initialValue ?? widget.methods.first;
}
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<PaymentMethod>(
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<PaymentMethod>(
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);
}
},
);
}
}

View File

@@ -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,
};
}

View File

@@ -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<PaymentType, Object> availableTypes;
final PaymentType selectedType;
final ValueChanged<PaymentType> 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(),
);
}
}

View File

@@ -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<void> 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);
}

View File

@@ -0,0 +1,29 @@
import 'dart:async';
import 'package:flutter/material.dart';
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(
ScaffoldMessengerState sm,
String message,
{ int delaySeconds = 3 }
) => sm.showSnackBar(SnackBar(content: Text(message), duration: Duration(seconds: delaySeconds)));
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUser(
BuildContext context,
String message,
{ int delaySeconds = 3 }
) => notifyUserX(ScaffoldMessenger.of(context), message, delaySeconds: delaySeconds);
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser(
BuildContext context, String message, {int delaySeconds = 3}) {
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = notifyUser(context, message, delaySeconds: delaySeconds);
completer.complete(controller);
});
return completer.future;
}

View File

@@ -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;
}

View File

@@ -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,
)
);
}

View File

@@ -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)}';
}