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

View File

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

View File

@@ -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<int>(
tooltip: AppLocalizations.of(context)!.profile,
onSelected: (value) {
if (value == 1) onLogout?.call();
},
itemBuilder: (_) => [
PopupMenuItem<int>(
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,
),
);
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class ConstrainedForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final List<Widget> 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,
),
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -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<AccountProvider>(context, listen: false);
accountProvider.logout();
navigateAndReplace(context, Pages.login);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<PermissionsProvider>(builder:(context, provider, _) =>
ListView(
padding: EdgeInsets.zero,
children: <Widget>[
// 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(),
],
),
),
);
}

View File

@@ -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<EmployeesProvider>(builder: (context, provider, _) => EmployeeAvatar(
radius: radius,
avatarUrl: provider.getEmployee(employeeRef)?.avatarUrl,
employeeName: provider.getEmployee(employeeRef)?.name ?? '',
));
}

View File

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

View File

@@ -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<EmployeesProvider>(builder: (context, provider, _) {
if (provider.isLoading) return const Center(child: CircularProgressIndicator());
return EmployeeTile.fromEmployee(
context: context,
employee: provider.getEmployee(employeeRef),
avatarRadius: avatarRadius,
);
});
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ValidationRule> rules, String value) {
return PasswordValidationOutput(
children: rules.map(
(rule) => PasswordValidationResult(
ruleName: rule.name,
result: rule.validate(value),
),
).toList()
);
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:pweb/widgets/vspacer.dart';
class PasswordValidationOutput extends StatelessWidget {
final List<Widget> 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,
)
]
);
}
}

View File

@@ -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<bool>? onValid;
final bool hasStrengthIndicator;
final String? labelText;
final Set<ValidationRule> rules;
final AutovalidateMode? autovalidateMode;
final Widget Function(Set<ValidationRule>, 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<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
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<bool>? onValid,
Widget Function(Set<ValidationRule>, String)? validationRuleBuilder,
String? labelText,
FocusNode? focusNode,
AutovalidateMode? autovalidateMode,
bool hasStrengthIndicator = false,
Set<ValidationRule> additionalRules = const {},
}) {
Set<ValidationRule> 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,
);
}

View File

@@ -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<bool>? onValid;
final TextEditingController controller;
final TextEditingController externalPasswordController;
const VerifyPasswordField({
super.key,
this.onValid,
required this.controller,
required this.externalPasswordController,
});
@override
State<VerifyPasswordField> createState() => _VerifyPasswordFieldState();
}
class _VerifyPasswordFieldState extends State<VerifyPasswordField> {
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();
}
}

View File

@@ -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<T extends Widget>(BuildContext context, ResourceType resource, T child, {perm.Action? action}) {
return protectedWidget(Provider.of<PermissionsProvider>(context, listen: false), resource, child, action: action);
}
T? protectedWidget<T extends Widget>(PermissionsProvider provider, ResourceType resource, T child, {perm.Action? action}) {
return provider.canAccessResource(resource, action: action) ? child : null;
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class SearchBox extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> 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<TextEditingValue>(
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(),
),
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class NotEmptyTextFormField extends StatelessWidget {
final String labelText;
final String error;
final TextEditingController controller;
final ValueChanged<bool>? 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,
);
}
}

View File

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

View File

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