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,144 @@
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import 'package:pshared/api/errors/unauthorized.dart';
import 'package:pshared/api/requests/signup.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/account.dart';
class AccountProvider extends ChangeNotifier {
// The resource now wraps our Account? state along with its loading/error state.
Resource<Account?> _resource = Resource(data: null);
Resource<Account?> get resource => _resource;
Account? get account => _resource.data;
bool get isLoggedIn => account != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
// Private helper to update the resource and notify listeners.
void _setResource(Resource<Account?> newResource) {
_resource = newResource;
notifyListeners();
}
Future<Account?> login({
required String email,
required String password,
required String locale,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await AccountService.login(email, password, locale);
_setResource(Resource(data: acc, isLoading: false));
return acc;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<Account?> restore() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await AccountService.restore();
_setResource(Resource(data: acc, isLoading: false));
return acc;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> signup(
String name,
String login,
String password,
String locale,
String organizationName,
String timezone,
) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.signup(
SignupRequest.build(
name: name,
login: login.trim().toLowerCase(),
password: password,
locale: locale,
organizationName: organizationName,
organizationTimeZone: timezone,
),
);
// Signup might not automatically log in the user,
// so we just mark the request as complete.
_setResource(_resource.copyWith(isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> logout() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.logout();
_setResource(Resource(data: null, isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<Account?> update({
String? locale,
String? avatarUrl,
String? notificationFrequency,
}) async {
if (account == null) throw ErrorUnauthorized();
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final updated = await AccountService.update(
account!.copyWith(
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
locale: locale ?? account!.locale,
),
);
_setResource(Resource(data: updated, isLoading: false));
return updated;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> changePassword(String oldPassword, String newPassword) async {
if (account == null) throw ErrorUnauthorized();
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final updated = await AccountService.changePassword(oldPassword, newPassword);
_setResource(Resource(data: updated, isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<Account?> uploadAvatar(XFile avatarFile) async {
if (account == null) throw ErrorUnauthorized();
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final avatarUrl = await AccountService.uploadAvatar(account!.id, avatarFile);
// Reuse the update method to update the avatar URL.
return update(avatarUrl: avatarUrl);
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/organization/employee.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/accounts/employees.dart';
class EmployeesProvider extends ChangeNotifier {
Resource<List<Employee>> _employees = Resource<List<Employee>>(data: []);
List<Employee> get employees => _employees.data ?? [];
bool get isLoading => _employees.isLoading;
Object? get error => _employees.error;
Employee? getEmployee(String? employeeRef) => employees.firstWhereOrNull((employee) => employee.id == employeeRef);
bool Function(Employee)? _filterPredicate;
List<Employee> get filteredItems => _filterPredicate != null
? employees.where(_filterPredicate!).toList()
: employees;
void setFilterPredicate(bool Function(Employee)? predicate) {
_filterPredicate = predicate;
notifyListeners();
}
void clearFilter() => setFilterPredicate(null);
void updateProviders(OrganizationsProvider organizations) {
load(organizations.current.id);
}
Future<List<Employee>> load(String organizationRef) async {
_employees = _employees.copyWith(isLoading: true, error: null);
notifyListeners();
try {
final fetchedEmployees = await EmployeesService.list(organizationRef);
_employees = _employees.copyWith(
data: fetchedEmployees,
isLoading: false,
error: null,
);
} catch (e) {
_employees = _employees.copyWith(
error: e is Exception ? e : Exception('Unknown error: ${e.toString()}'),
isLoading: false,
);
}
notifyListeners();
return employees;
}
}

View File

@@ -0,0 +1,3 @@
Exception toException(Object e) {
return e is Exception ? e : Exception(e.toString());
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:pshared/config/constants.dart';
class LocaleProvider with ChangeNotifier {
Locale _locale = Constants.defaultLocale;
Locale stringToLocale(String localeString) {
var parts = localeString.split(RegExp(r'[-_]'));
return (parts.length > 1) ? Locale(parts[0], parts[1]) : Locale(parts[0]);
}
LocaleProvider(String? localeCode) {
if (localeCode != null) {
_locale = stringToLocale(localeCode);
}
}
Locale get locale => _locale;
void setLocale(Locale locale) {
if (_locale == locale) return;
_locale = locale;
notifyListeners();
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/organization/organization.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/service/organization.dart';
import 'package:pshared/service/secure_storage.dart';
class OrganizationsProvider extends ChangeNotifier {
Resource<List<Organization>> _resource = Resource(data: []);
Resource<List<Organization>> get resource => _resource;
List<Organization> get organizations => _resource.data ?? [];
String? _currentOrg;
Organization get current => isOrganizationSet ? _current! : throw StateError('Organization is not set');
Organization? _org(String? orgRef) => organizations.firstWhereOrNull((org) => org.id == orgRef);
Organization? get _current => _org(_currentOrg);
bool get isOrganizationSet => _current != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
void _setResource(Resource<List<Organization>> newResource) {
_resource = newResource;
notifyListeners();
}
Future<List<Organization>> load() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final orgs = await OrganizationService.list();
// fetch stored org
String? org = await SecureStorageService.get(Constants.currentOrgKey);
// check stored org availability
org = orgs.firstWhereOrNull((o) => o.id == org)?.id;
// fallback if org is not set or not available
org ??= orgs.first.id;
await setCurrentOrganization(org);
_setResource(Resource(data: orgs, isLoading: false));
return orgs;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<Organization> loadByInvitation(String invitationRef) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final org = await OrganizationService.loadByInvitation(invitationRef);
await setCurrentOrganization(org.id);
_setResource(Resource(data: [org], isLoading: false));
return org;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
bool _setCurrentOrganization(String? orgRef) {
final organizationRef = _org(orgRef)?.id;
if (organizationRef == null) return false;
_currentOrg = organizationRef;
return true;
}
Future<bool> setCurrentOrganization(String? orgRef) async {
if (!_setCurrentOrganization(orgRef)) return false;
await SecureStorageService.set(Constants.currentOrgKey, orgRef);
notifyListeners();
return true;
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/api/requests/change_role.dart';
import 'package:pshared/models/permissions/access.dart';
import 'package:pshared/models/permissions/action.dart' as perm;
import 'package:pshared/models/permissions/data/permission.dart';
import 'package:pshared/models/permissions/data/policy.dart';
import 'package:pshared/models/permissions/data/role.dart';
import 'package:pshared/models/permissions/descriptions/policy.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pshared/models/permissions/effect.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/permissions.dart';
class PermissionsProvider extends ChangeNotifier {
Resource<UserAccess> _userAccess = Resource(data: null, isLoading: false, error: null);
late OrganizationsProvider _organizations;
void update(OrganizationsProvider venue) {
_organizations = venue;
}
// Generic wrapper to perform service calls and reload state
Future<UserAccess?> _performServiceCall(Future Function() operation) async {
try {
await operation();
return await load();
} catch (e) {
_userAccess = _userAccess.copyWith(
error: e is Exception ? e : Exception(e.toString()),
isLoading: false,
);
notifyListeners();
return _userAccess.data;
}
}
/// Load the [UserAccess] for the current venue.
Future<UserAccess?> load() async {
_userAccess = _userAccess.copyWith(isLoading: true, error: null);
notifyListeners();
try {
final orgRef = _organizations.current.id;
final access = await PermissionsService.load(orgRef);
_userAccess = _userAccess.copyWith(data: access, isLoading: false);
if (canRead(ResourceType.roles)) {
final allAccess = await PermissionsService.loadAll(orgRef);
_userAccess = _userAccess.copyWith(data: allAccess, isLoading: false);
}
} catch (e) {
_userAccess = _userAccess.copyWith(
error: e is Exception ? e : Exception(e.toString()),
isLoading: false,
);
}
notifyListeners();
return _userAccess.data;
}
Future<UserAccess?> changeRole(String accountRef, String newRoleDescRef) async {
final currentRole = roles.firstWhereOrNull((r) => r.accountRef == accountRef);
final currentDesc = currentRole != null
? roleDescriptions.firstWhereOrNull((d) => d.storable.id == currentRole.descriptionRef)
: null;
if (currentRole == null || currentDesc == null || currentDesc.storable.id == newRoleDescRef) {
return _userAccess.data;
}
return _performServiceCall(() => PermissionsService.changeRole(
_organizations.current.id,
ChangeRole(accountRef: accountRef, newRoleDescriptionRef: newRoleDescRef),
));
}
Future<UserAccess?> deleteRoleDescription(String descRef) {
return _performServiceCall(() => PermissionsService.deleteRoleDescription(descRef));
}
Future<UserAccess?> createPermissions(List<Policy> policies) {
return _performServiceCall(() => PermissionsService.createPolicies(policies));
}
Future<UserAccess?> deletePermissions(List<Policy> policies) {
return _performServiceCall(() => PermissionsService.deletePolicies(policies));
}
Future<UserAccess?> changePermissions(List<Policy> add, List<Policy> remove) {
return _performServiceCall(() => PermissionsService.changePolicies(add, remove));
}
// -- Data getters --
Set<ResourceType> extractResourceTypes(Iterable<PolicyDescription> descriptions) => descriptions.expand((policy) => policy.resourceTypes ?? <ResourceType>[]).toSet();
Set<ResourceType> get resources => Set.unmodifiable(extractResourceTypes(policyDescriptions));
Set<ResourceType> getRoleResources(String roleDescRef) => Set.unmodifiable(
extractResourceTypes(
getRolePermissions(roleDescRef)
.map((p) => getPolicyDescription(p.policy.descriptionRef))
.whereType<PolicyDescription>(),
),
);
String? getPolicyDescriptionRef(ResourceType resource) => policyDescriptions.firstWhereOrNull((p) => p.resourceTypes?.contains(resource) ?? false)?.storable.id;
List<PolicyDescription> get policyDescriptions => List.unmodifiable(_userAccess.data?.descriptions.policies ?? []);
List<RoleDescription> get roleDescriptions => List.unmodifiable(_userAccess.data?.descriptions.roles ?? []);
List<Permission> get permissions => List.unmodifiable(_userAccess.data?.permissions.permissions ?? []);
List<Policy> get policies => List.unmodifiable(_userAccess.data?.permissions.policies ?? []);
List<Role> get roles => List.unmodifiable(_userAccess.data?.permissions.roles ?? []);
Role? getRole(String accountRef) => roles.firstWhereOrNull((r) => r.accountRef == accountRef);
RoleDescription? getRoleDescription(String descRef) => roleDescriptions.firstWhereOrNull((d) => d.storable.id == descRef);
List<Role> getRoles(String accountRef) => roles.where((r) => r.accountRef == accountRef).toList();
List<Policy> getRolePolicies(String roleRef) => policies.where((p) => p.roleDescriptionRef == roleRef).toList();
List<Permission> getRolePermissions(String descRef) => permissions.where((p) => p.policy.roleDescriptionRef == descRef).toList();
PolicyDescription? getPolicyDescription(String policyRef) => policyDescriptions.firstWhereOrNull((p) => p.storable.id == policyRef);
// -- Permission checks --
bool get isLoading => _userAccess.isLoading;
bool get isReady => !_userAccess.isLoading && error == null;
Exception? get error => _userAccess.error;
bool _hasMatchingPermission(
PolicyDescription pd,
Effect effect,
perm.Action? action, {
Object? objectRef,
}) => permissions.firstWhereOrNull(
(p) =>
p.policy.descriptionRef == pd.storable.id &&
p.policy.effect.effect == effect &&
(action == null || p.policy.effect.action == action) &&
(p.policy.objectRef == null || p.policy.objectRef == objectRef),
) != null;
bool canAccessResource(
ResourceType resource, {
perm.Action? action,
Object? objectRef,
}) {
final orgId = _organizations.current.id;
final pd = policyDescriptions.firstWhereOrNull(
(policy) =>
(policy.resourceTypes?.contains(resource) ?? false) &&
(policy.organizationRef == null || policy.organizationRef == orgId),
);
if (pd == null) return false;
if (_hasMatchingPermission(pd, Effect.deny, action, objectRef: objectRef)) return false;
return _hasMatchingPermission(pd, Effect.allow, action, objectRef: objectRef);
}
bool canRead(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.read, objectRef: objectRef);
bool canUpdate(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.update, objectRef: objectRef);
bool canDelete(ResourceType r, {Object? objectRef}) => canAccessResource(r, action: perm.Action.delete, objectRef: objectRef);
bool canCreate(ResourceType r) => canAccessResource(r, action: perm.Action.create);
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:pshared/service/pfe/service.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/account.dart';
class PfeProvider extends ChangeNotifier {
// The resource now wraps our Account? state along with its loading/error state.
Resource<String?> _resource = Resource(data: null);
Resource<String?> get resource => _resource;
String? get session => _resource.data;
bool get isLoggedIn => session != null;
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
// Private helper to update the resource and notify listeners.
void _setResource(Resource<String?> newResource) {
_resource = newResource;
notifyListeners();
}
Future<String?> login({
required String email,
required String password,
}) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final acc = await PfeService.login(email, password);
_setResource(Resource(data: acc, isLoading: false));
return acc;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> logout() async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
await AccountService.logout();
_setResource(Resource(data: null, isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
}

View File

@@ -0,0 +1,15 @@
class Resource<T> {
final T? data;
final bool isLoading;
final Exception? error;
Resource({this.data, this.isLoading = false, this.error});
Resource<T> copyWith({T? data, bool? isLoading, Exception? error}) {
return Resource<T>(
data: data ?? this.data,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/permission_bound_storable.dart';
import 'package:pshared/provider/exception.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/template.dart';
List<T> mergeLists<T>({
required List<T> lhs,
required List<T> rhs,
required Comparable Function(T) getKey, // Extracts ID dynamically
required int Function(T, T) compare,
required T Function(T, T) merge,
}) {
final result = <T>[];
final map = {for (var item in lhs) getKey(item): item};
for (var updated in rhs) {
final key = getKey(updated);
map[key] = merge(map[key] ?? updated, updated);
}
result.addAll(map.values);
result.sort(compare);
return result;
}
/// A generic provider that wraps a [BasicService] instance
/// to manage state (loading, error, data) without reimplementing service logic.
class GenericProvider<T extends PermissionBoundStorable> extends ChangeNotifier {
final BasicService<T> service;
Resource<List<T>> _resource = Resource(data: []);
Resource<List<T>> get resource => _resource;
List<T> get items => List.unmodifiable(_resource.data ?? []);
bool get isLoading => _resource.isLoading;
bool get isEmpty => items.isEmpty;
Object? get error => _resource.error;
String? _currentObjectRef; // Stores the currently selected project ref
T? get currentObject => _resource.data?.firstWhereOrNull(
(object) => object.id == _currentObjectRef,
);
T? getItemById(String id) => items.firstWhereOrNull((item) => item.id == id);
GenericProvider({required this.service});
bool Function(T)? _filterPredicate;
List<T> get filteredItems => _filterPredicate != null ? items.where(_filterPredicate!).toList() : items;
void setFilterPredicate(bool Function(T)? predicate) {
_filterPredicate = predicate;
notifyListeners();
}
void clearFilter() => setFilterPredicate(null);
void _setResource(Resource<List<T>> newResource) {
_resource = newResource;
notifyListeners();
}
Future<void> loadFuture(Future<List<T>> future) async {
_setResource(_resource.copyWith(isLoading: true));
try {
final list = await future;
_setResource(Resource(data: list, isLoading: false));
} catch (e) {
_setResource(
_resource.copyWith(isLoading: false, error: toException(e)),
);
rethrow;
}
}
Future<void> load(String organizationRef, String? parentRef) async {
if (parentRef != null) {
return loadFuture(service.list(organizationRef, parentRef));
}
}
Future<void> loadItem(String itemRef) async {
return loadFuture((() async => [await service.get(itemRef)])());
}
List<T> merge(List<T> rhs) => mergeLists<T>(
lhs: items,
rhs: rhs,
getKey: (item) => item.id, // Key extractor
compare: (a, b) => a.id.compareTo(b.id), // Sorting logic
merge: (existing, updated) => updated, // Replace with the updated version
);
Future<T> get(String objectRef) async {
_setResource(_resource.copyWith(isLoading: true));
try {
final item = await service.get(objectRef);
_setResource(Resource(data: merge([item]), isLoading: false));
return item;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<T> createObject(String organizationRef, Map<String, dynamic> request) async {
_setResource(_resource.copyWith(isLoading: true));
try {
final newObject = await service.create(organizationRef, request);
_setResource(Resource(data: [...items, ...newObject], isLoading: false));
return newObject.first;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> update(Map<String, dynamic> request) async {
_setResource(_resource.copyWith(isLoading: true));
try {
final list = await service.update(request);
_setResource(Resource(data: merge(list), isLoading: false));
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> delete(String objectRef) async {
_setResource(_resource.copyWith(isLoading: true));
try {
await service.delete(objectRef);
if (_currentObjectRef == objectRef) {
_currentObjectRef = null;
}
_setResource(Resource(
data: _resource.data?.where((p) => p.id != objectRef).toList(),
isLoading: false,
));
} catch (e) {
_setResource(Resource(data: _resource.data, isLoading: false, error: toException(e)));
rethrow;
}
}
bool setCurrentObject(String? objectRef) {
if (objectRef == null) {
_currentObjectRef = null;
notifyListeners();
return true;
}
if (_resource.data?.any((p) => p.id == objectRef) ?? false) {
_currentObjectRef = objectRef;
notifyListeners();
return true;
}
return false; // Object not found
}
}