Frontend first draft
This commit is contained in:
48
frontend/pweb/lib/widgets/appbar/app_bar.dart
Normal file
48
frontend/pweb/lib/widgets/appbar/app_bar.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
13
frontend/pweb/lib/widgets/appbar/notifications.dart
Normal file
13
frontend/pweb/lib/widgets/appbar/notifications.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
frontend/pweb/lib/widgets/appbar/profile.dart
Normal file
40
frontend/pweb/lib/widgets/appbar/profile.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
35
frontend/pweb/lib/widgets/constrained_form.dart
Normal file
35
frontend/pweb/lib/widgets/constrained_form.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
frontend/pweb/lib/widgets/drawer/avatar.dart
Normal file
30
frontend/pweb/lib/widgets/drawer/avatar.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
frontend/pweb/lib/widgets/drawer/tiles/dashboard.dart
Normal file
19
frontend/pweb/lib/widgets/drawer/tiles/dashboard.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
frontend/pweb/lib/widgets/drawer/tiles/logout.dart
Normal file
32
frontend/pweb/lib/widgets/drawer/tiles/logout.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
19
frontend/pweb/lib/widgets/drawer/tiles/settings/profile.dart
Normal file
19
frontend/pweb/lib/widgets/drawer/tiles/settings/profile.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
19
frontend/pweb/lib/widgets/drawer/tiles/settings/roles.dart
Normal file
19
frontend/pweb/lib/widgets/drawer/tiles/settings/roles.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
21
frontend/pweb/lib/widgets/drawer/tiles/settings/users.dart
Normal file
21
frontend/pweb/lib/widgets/drawer/tiles/settings/users.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
frontend/pweb/lib/widgets/drawer/widget.dart
Normal file
45
frontend/pweb/lib/widgets/drawer/widget.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
26
frontend/pweb/lib/widgets/employee/avatar/provider.dart
Normal file
26
frontend/pweb/lib/widgets/employee/avatar/provider.dart
Normal 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 ?? '',
|
||||
));
|
||||
}
|
||||
29
frontend/pweb/lib/widgets/employee/avatar/widget.dart
Normal file
29
frontend/pweb/lib/widgets/employee/avatar/widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
26
frontend/pweb/lib/widgets/employee/provider.dart
Normal file
26
frontend/pweb/lib/widgets/employee/provider.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
34
frontend/pweb/lib/widgets/employee/tile.dart
Normal file
34
frontend/pweb/lib/widgets/employee/tile.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
29
frontend/pweb/lib/widgets/error/content.dart
Normal file
29
frontend/pweb/lib/widgets/error/content.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
132
frontend/pweb/lib/widgets/error/snackbar.dart
Normal file
132
frontend/pweb/lib/widgets/error/snackbar.dart
Normal 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),
|
||||
)),
|
||||
),
|
||||
);
|
||||
24
frontend/pweb/lib/widgets/footer/labels.dart
Normal file
24
frontend/pweb/lib/widgets/footer/labels.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
58
frontend/pweb/lib/widgets/footer/policies.dart
Normal file
58
frontend/pweb/lib/widgets/footer/policies.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
49
frontend/pweb/lib/widgets/footer/support.dart
Normal file
49
frontend/pweb/lib/widgets/footer/support.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
frontend/pweb/lib/widgets/footer/widget.dart
Normal file
28
frontend/pweb/lib/widgets/footer/widget.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
frontend/pweb/lib/widgets/hspacer.dart
Normal file
14
frontend/pweb/lib/widgets/hspacer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
15
frontend/pweb/lib/widgets/logo.dart
Normal file
15
frontend/pweb/lib/widgets/logo.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
23
frontend/pweb/lib/widgets/password/hint/error.dart
Normal file
23
frontend/pweb/lib/widgets/password/hint/error.dart
Normal 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,
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
18
frontend/pweb/lib/widgets/password/hint/full.dart
Normal file
18
frontend/pweb/lib/widgets/password/hint/full.dart
Normal 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()
|
||||
);
|
||||
}
|
||||
25
frontend/pweb/lib/widgets/password/hint/short.dart
Normal file
25
frontend/pweb/lib/widgets/password/hint/short.dart
Normal 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();
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
23
frontend/pweb/lib/widgets/password/hint/widget.dart
Normal file
23
frontend/pweb/lib/widgets/password/hint/widget.dart
Normal 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,
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
104
frontend/pweb/lib/widgets/password/password.dart
Normal file
104
frontend/pweb/lib/widgets/password/password.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
95
frontend/pweb/lib/widgets/password/verify.dart
Normal file
95
frontend/pweb/lib/widgets/password/verify.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
16
frontend/pweb/lib/widgets/protected/widget.dart
Normal file
16
frontend/pweb/lib/widgets/protected/widget.dart
Normal 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;
|
||||
}
|
||||
52
frontend/pweb/lib/widgets/search.dart
Normal file
52
frontend/pweb/lib/widgets/search.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
46
frontend/pweb/lib/widgets/sidebar/destinations.dart
Normal file
46
frontend/pweb/lib/widgets/sidebar/destinations.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
111
frontend/pweb/lib/widgets/sidebar/page.dart
Normal file
111
frontend/pweb/lib/widgets/sidebar/page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
frontend/pweb/lib/widgets/sidebar/side_menu.dart
Normal file
79
frontend/pweb/lib/widgets/sidebar/side_menu.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
frontend/pweb/lib/widgets/sidebar/sidebar.dart
Normal file
59
frontend/pweb/lib/widgets/sidebar/sidebar.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
70
frontend/pweb/lib/widgets/sidebar/user.dart
Normal file
70
frontend/pweb/lib/widgets/sidebar/user.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
frontend/pweb/lib/widgets/stats/card.dart
Normal file
47
frontend/pweb/lib/widgets/stats/card.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
frontend/pweb/lib/widgets/text_field.dart
Normal file
38
frontend/pweb/lib/widgets/text_field.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
43
frontend/pweb/lib/widgets/username.dart
Normal file
43
frontend/pweb/lib/widgets/username.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
14
frontend/pweb/lib/widgets/vspacer.dart
Normal file
14
frontend/pweb/lib/widgets/vspacer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user