Compare commits

..

49 Commits

Author SHA1 Message Date
27349a6cc0 Implemented PR notes by extracting widgets into their own classes. 2025-06-23 09:24:53 +03:00
41d4fbb555 Extracted pagination data into a generic DTO. 2025-06-22 16:00:20 +03:00
28ac911f3f Accomodated for null values in SpaceModel. 2025-06-22 15:30:47 +03:00
09446844b0 reverted initializing the new space management page in the router, to avoid any confusion with the QA team. 2025-06-22 15:11:38 +03:00
f02788eaa5 implemented create community feature. 2025-06-22 14:58:38 +03:00
b79ab06d95 shows a loading indicator when loading. 2025-06-22 12:58:45 +03:00
8494f0a8f1 matched community and space models with API. 2025-06-22 12:38:54 +03:00
65ed94eb08 debounce and refactored CommunitiesBloc. 2025-06-22 12:01:32 +03:00
51c088d998 made communities paginatable. 2025-06-22 11:11:25 +03:00
2f233db332 implemented space management side bar. 2025-06-22 11:04:39 +03:00
20d044f2e5 Merge branch 'dev' of https://github.com/SyncrowIOT/web into SP-1710-FE-Create-Sidebar 2025-06-18 09:44:35 +03:00
8caee32822 Initialized new SpaceManagementPage. 2025-06-18 09:39:49 +03:00
6c4bc0d634 Sp 1709 fe blocs and services (#260)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1709](https://syncrow.atlassian.net/browse/SP-1709)

## Description

Created blocs, services, params, and models for those parts of the space
management module:

1. Communities
2. Update Community
3. Create Community
4. Products
5. Space Details
6. Tags
7. Update Community
8. Update Space

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore 


[SP-1709]:
https://syncrow.atlassian.net/browse/SP-1709?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-18 09:14:04 +03:00
8fc6e54ecc SP-1737-FE-The-user-appears-as-Null-and-the-project-uuid-is-null-when-we-login-in-after-a-credentials-error (#259)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1723](https://syncrow.atlassian.net/browse/SP-1723)

## Description

Loads user data on the initial state of `HomePage` instead of loading it
in the `MaterialApp`, because in the previous solution that caused
temporal coupling.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [x] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore 


[SP-1723]:
https://syncrow.atlassian.net/browse/SP-1723?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-06-17 14:33:06 +03:00
5d3380ef82 fixed merge conflict. 2025-06-17 14:30:34 +03:00
5b0710957d Merge branch 'dev' into SP-1737-FE-The-user-appears-as-Null-and-the-project-uuid-is-null-when-we-login-in-after-a-credentials-error 2025-06-17 14:26:28 +03:00
35f975b261 Merge branch 'main' into dev 2025-06-17 09:17:19 +03:00
96f463229c SP-1723-FE-Integrate-Charts-with-API-s-for-AQI-sensor. 2025-06-16 13:12:57 +03:00
1567f10827 Revert "enhanced ci/cd by not running the deply jobs on the PR itself… (#237)
…, and now we only deploy when we merged a PR to `dev` or `main`, and
created a separate GitHub action that only builds and install
dependencies, which only runs on the PR itself."

This reverts commit f19120c754.

<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Description

<!--- Describe your changes in detail -->

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [x] 🗑️ Chore
2025-06-11 09:53:19 +03:00
cdbd90b54c Revert "enhanced ci/cd by not running the deply jobs on the PR itself, and now we only deploy when we merged a PR to dev or main, and created a separate GitHub action that only builds and install dependencies, which only runs on the PR itself."
This reverts commit f19120c754.
2025-06-11 09:52:08 +03:00
03f5c869c6 chore/add-dependabot (#232)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Description

Added `dependabot` configuration, which notifies us of any updated
dependencies, and if there is security aspects that we'd need to take
care of.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x] 🗑️ Chore
2025-06-04 16:41:42 +03:00
4f98891902 Created dependabot.yaml 2025-06-04 13:13:07 +03:00
7002bbfa04 CI/CD Enhancements (#230)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->
## Description

enhanced CI/CD by not running the deploy jobs on the PR itself, and now
we only deploy when we merged a PR to `dev` or `main`, and created a
separate GitHub action that only builds and install dependencies, which
only runs on the PR itself.


## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ ]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [x] 🗑️ Chore
2025-06-04 10:41:26 +03:00
f19120c754 enhanced ci/cd by not running the deply jobs on the PR itself, and now we only deploy when we merged a PR to dev or main, and created a separate GitHub action that only builds and install dependencies, which only runs on the PR itself. 2025-06-04 10:12:19 +03:00
6b3eca23af Update pull_request_template.md 2025-05-28 16:46:24 +03:00
4f4f11c330 Merge branch 'main' of https://github.com/SyncrowIOT/web 2025-05-28 14:26:36 +03:00
8a25fa798c Created pull_request_template.md. 2025-05-28 14:26:33 +03:00
6612e91430 Merge pull request #177 from SyncrowIOT/merge_sprint_19_bugfixes
merged DEV into staging.
2025-05-08 14:32:54 +03:00
56c613fb0c Disabled Syncrow Analytics feature for release purposes. 2025-05-08 14:32:08 +03:00
8d2d9dd0bb Merge branch 'dev' of https://github.com/SyncrowIOT/web 2025-04-29 10:39:54 +03:00
cfc68f1568 Merge pull request #116 from SyncrowIOT/dev
fix real time listenToChanges
2025-03-12 21:26:45 +03:00
02e08ad92f Merge pull request #115 from SyncrowIOT/dev
Dev
2025-03-12 14:21:13 +03:00
d7899a24f5 Merged with dev 2025-02-20 13:03:38 +03:00
800c0ba47f Merge pull request #101 from SyncrowIOT/bugfix/fix-endpoint
Main
2025-02-20 13:37:28 +04:00
fe4e775902 fixed endpoint 2025-02-20 13:35:59 +04:00
5247856cb4 Merge pull request #99 from SyncrowIOT:bugfix/add-tag-border
added back border of tag list
2025-02-20 11:50:07 +04:00
4a8b8a32ba added back border of tag list 2025-02-20 11:49:35 +04:00
2abce77eb5 Merge pull request #97 from SyncrowIOT/feat/fix-cursor-issue-in-main
Fixed cursor issue
2025-02-20 11:35:35 +04:00
7efd1c3c87 Fixed cursor issue 2025-02-20 11:34:24 +04:00
7a0d9aefb7 Merge pull request #95 from SyncrowIOT/bugfix/change-endpoint-prod
fix endpoints
2025-02-19 18:01:11 +04:00
21cc25cfc4 fix endpoints 2025-02-19 17:58:53 +04:00
e2ec4bbf31 Pulled main changes 2025-02-18 16:27:09 +03:00
51b46ae197 Merged with dev 2025-02-18 16:25:33 +03:00
36ee22603a fixes CommunityId and spaceUuid 2025-02-10 12:44:35 +03:00
b0abd42b0c projectId 2025-02-06 11:28:40 +03:00
ba4da78846 Merge branch 'dev' 2025-02-06 11:20:34 +03:00
dc20d69f20 Merge pull request #88 from SyncrowIOT/dev
Dev
2025-02-06 01:03:32 +03:00
cf6ec231dc Merged with dev 2025-02-06 00:57:29 +03:00
d0530f7fc3 Added staging space and community IDs 2024-12-04 09:57:29 +03:00
44 changed files with 1476 additions and 170 deletions

10
.github/.github/dependabot.yaml vendored Normal file
View File

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "daily"

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class AppLoadingIndicator extends StatelessWidget {
const AppLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}

View File

@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart';
import 'package:syncrow_web/firebase_options_prod.dart';
import 'package:syncrow_web/pages/auth/bloc/auth_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_event.dart';
import 'package:syncrow_web/pages/routines/bloc/create_routine_bloc/create_routine_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/routine_bloc/routine_bloc.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
@ -21,8 +20,10 @@ import 'package:syncrow_web/utils/theme/theme.dart';
Future<void> main() async {
try {
const environment =
String.fromEnvironment('FLAVOR', defaultValue: 'production');
const environment = String.fromEnvironment(
'FLAVOR',
defaultValue: 'production',
);
await dotenv.load(fileName: '.env.$environment');
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
@ -40,7 +41,7 @@ class MyApp extends StatelessWidget {
initialLocation: RoutesConst.auth,
routes: AppRoutes.getRoutes(),
redirect: (context, state) async {
String checkToken = await AuthBloc.getTokenAndValidate();
final checkToken = await AuthBloc.getTokenAndValidate();
final loggedIn = checkToken == 'Success';
final goingToLogin = state.uri.toString() == RoutesConst.auth;
@ -58,8 +59,7 @@ class MyApp extends StatelessWidget {
BlocProvider<CreateRoutineBloc>(
create: (context) => CreateRoutineBloc(),
),
BlocProvider(
create: (context) => HomeBloc()..add(const FetchUserInfo())),
BlocProvider(create: (context) => HomeBloc()),
BlocProvider<VisitorPasswordBloc>(
create: (context) => VisitorPasswordBloc(),
),

View File

@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart';
import 'package:syncrow_web/firebase_options_dev.dart';
import 'package:syncrow_web/pages/auth/bloc/auth_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_event.dart';
import 'package:syncrow_web/pages/routines/bloc/create_routine_bloc/create_routine_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/routine_bloc/routine_bloc.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
@ -21,7 +20,10 @@ import 'package:syncrow_web/utils/theme/theme.dart';
Future<void> main() async {
try {
const environment = String.fromEnvironment('FLAVOR', defaultValue: 'development');
const environment = String.fromEnvironment(
'FLAVOR',
defaultValue: 'development',
);
await dotenv.load(fileName: '.env.$environment');
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
@ -39,7 +41,7 @@ class MyApp extends StatelessWidget {
initialLocation: RoutesConst.auth,
routes: AppRoutes.getRoutes(),
redirect: (context, state) async {
String checkToken = await AuthBloc.getTokenAndValidate();
final checkToken = await AuthBloc.getTokenAndValidate();
final loggedIn = checkToken == 'Success';
final goingToLogin = state.uri.toString() == RoutesConst.auth;
@ -57,7 +59,7 @@ class MyApp extends StatelessWidget {
BlocProvider<CreateRoutineBloc>(
create: (context) => CreateRoutineBloc(),
),
BlocProvider(create: (context) => HomeBloc()..add(const FetchUserInfo())),
BlocProvider(create: (context) => HomeBloc()),
BlocProvider<VisitorPasswordBloc>(
create: (context) => VisitorPasswordBloc(),
),

View File

@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart';
import 'package:syncrow_web/firebase_options_prod.dart';
import 'package:syncrow_web/pages/auth/bloc/auth_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_event.dart';
import 'package:syncrow_web/pages/routines/bloc/create_routine_bloc/create_routine_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/routine_bloc/routine_bloc.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
@ -39,7 +38,7 @@ class MyApp extends StatelessWidget {
initialLocation: RoutesConst.auth,
routes: AppRoutes.getRoutes(),
redirect: (context, state) async {
String checkToken = await AuthBloc.getTokenAndValidate();
final checkToken = await AuthBloc.getTokenAndValidate();
final loggedIn = checkToken == 'Success';
final goingToLogin = state.uri.toString() == RoutesConst.auth;
@ -57,7 +56,7 @@ class MyApp extends StatelessWidget {
BlocProvider<CreateRoutineBloc>(
create: (context) => CreateRoutineBloc(),
),
BlocProvider(create: (context) => HomeBloc()..add(const FetchUserInfo())),
BlocProvider(create: (context) => HomeBloc()),
BlocProvider<VisitorPasswordBloc>(
create: (context) => VisitorPasswordBloc(),
),

View File

@ -13,30 +13,32 @@ import 'package:syncrow_web/pages/space_tree/bloc/space_tree_event.dart';
import 'package:syncrow_web/services/home_api.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/constants/routes_const.dart';
import 'package:syncrow_web/utils/navigation_service.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
UserModel? user;
String terms = '';
String policy = '';
HomeBloc() : super((HomeInitial())) {
// on<CreateNewNode>(_createNode);
HomeBloc() : super(HomeInitial()) {
on<FetchUserInfo>(_fetchUserInfo);
on<FetchTermEvent>(_fetchTerms);
on<FetchPolicyEvent>(_fetchPolicy);
on<ConfirmUserAgreementEvent>(_confirmUserAgreement);
}
Future _fetchUserInfo(FetchUserInfo event, Emitter<HomeState> emit) async {
Future<void> _fetchUserInfo(
FetchUserInfo event,
Emitter<HomeState> emit,
) async {
try {
var uuid =
final uuid =
await const FlutterSecureStorage().read(key: UserModel.userUuidKey);
if (uuid != null) {
user = await HomeApi().fetchUserInfo(uuid);
}
if (user != null && user!.project != null) {
if (user != null && user?.project != null) {
await ProjectManager.setProjectUUID(user!.project!.uuid);
}
add(FetchTermEvent());
add(FetchPolicyEvent());
@ -47,7 +49,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
}
}
Future _fetchTerms(FetchTermEvent event, Emitter<HomeState> emit) async {
Future<void> _fetchTerms(FetchTermEvent event, Emitter<HomeState> emit) async {
try {
emit(LoadingHome());
terms = await HomeApi().fetchTerms();
@ -57,22 +59,22 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
}
}
Future _fetchPolicy(FetchPolicyEvent event, Emitter<HomeState> emit) async {
Future<void> _fetchPolicy(FetchPolicyEvent event, Emitter<HomeState> emit) async {
try {
emit(LoadingHome());
policy = await HomeApi().fetchPolicy();
emit(HomeInitial());
} catch (e) {
debugPrint("Error fetching policy: $e");
debugPrint('Error fetching policy: $e');
return;
}
}
Future _confirmUserAgreement(
Future<void> _confirmUserAgreement(
ConfirmUserAgreementEvent event, Emitter<HomeState> emit) async {
try {
emit(LoadingHome());
var uuid =
final uuid =
await const FlutterSecureStorage().read(key: UserModel.userUuidKey);
policy = await HomeApi().confirmUserAgreements(uuid);
emit(PolicyAgreement());
@ -81,7 +83,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
}
}
List<HomeItemModel> homeItems = [
final List<HomeItemModel> homeItems = [
HomeItemModel(
title: 'Access Management',
icon: Assets.accessIcon,
@ -126,41 +128,5 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
},
color: const Color(0xFF023DFE),
),
// HomeItemModel(
// title: 'Move in',
// icon: Assets.moveinIcon,
// active: false,
// onPress: (context) {},
// color: ColorsManager.primaryColor,
// ),
// HomeItemModel(
// title: 'Construction',
// icon: Assets.constructionIcon,
// active: false,
// onPress: (context) {},
// color: ColorsManager.primaryColor,
// ),
// HomeItemModel(
// title: 'Energy',
// icon: Assets.energyIcon,
// active: false,
// onPress: (context) {},
// color: ColorsManager.slidingBlueColor.withOpacity(0.2),
// ),
// HomeItemModel(
// title: 'Integrations',
// icon: Assets.integrationsIcon,
// active: false,
// onPress: (context) {},
// color: ColorsManager.slidingBlueColor.withOpacity(0.2),
// ),
// HomeItemModel(
// title: 'Asset',
// icon: Assets.assetIcon,
// active: false,
// onPress: (context) {},
// color: ColorsManager.slidingBlueColor.withOpacity(0.2),
// ),
];
}

View File

@ -1,11 +1,25 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_bloc.dart';
import 'package:syncrow_web/pages/home/bloc/home_event.dart';
import 'package:syncrow_web/pages/home/view/home_page_mobile.dart';
import 'package:syncrow_web/pages/home/view/home_page_web.dart';
import 'package:syncrow_web/utils/helpers/responsice_layout_helper/responsive_layout_helper.dart';
class HomePage extends StatelessWidget with HelperResponsiveLayout {
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with HelperResponsiveLayout{
@override
void initState() {
context.read<HomeBloc>().add(const FetchUserInfo());
super.initState();
}
@override
Widget build(BuildContext context) {
final isSmallScreen = isSmallScreenSize(context);

View File

@ -19,7 +19,6 @@ import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class UsersPage extends StatelessWidget {
UsersPage({super.key});

View File

@ -4,8 +4,8 @@ import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/routines/bloc/automation_scene_trigger_bloc/automation_status_update.dart';
import 'package:syncrow_web/pages/routines/bloc/create_routine_bloc/create_routine_bloc.dart';
@ -27,9 +27,6 @@ import 'package:uuid/uuid.dart';
part 'routine_event.dart';
part 'routine_state.dart';
// String spaceId = '25c96044-fadf-44bb-93c7-3c079e527ce6';
// String communityId = 'aff21a57-2f91-4e5c-b99b-0182c3ab65a9';
class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
RoutineBloc() : super(const RoutineState()) {
on<AddToIfContainer>(_onAddToIfContainer);
@ -1163,8 +1160,8 @@ class RoutineBloc extends Bloc<RoutineEvent, RoutineState> {
if (result['success']) {
add(ResetRoutineState());
add(LoadAutomation());
add(LoadScenes());
add(const LoadAutomation());
add(const LoadScenes());
} else {
emit(state.copyWith(
isLoading: false,

View File

@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
class PaginatedDataModel<T> extends Equatable {
const PaginatedDataModel({
required this.data,
required this.page,
required this.size,
required this.hasNext,
required this.totalItems,
required this.totalPages,
});
final List<T> data;
final int page;
final int size;
final bool hasNext;
final int totalItems;
final int totalPages;
factory PaginatedDataModel.fromJson(
Map<String, dynamic> json,
List<T> Function(List<dynamic>) fromJsonList,
) {
return PaginatedDataModel<T>(
data: fromJsonList(json['data'] as List<dynamic>),
page: json['page'] as int? ?? 1,
size: json['size'] as int? ?? 25,
hasNext: json['hasNext'] as bool? ?? false,
totalItems: json['totalItem'] as int? ?? 0,
totalPages: json['totalPage'] as int? ?? 0,
);
}
@override
List<Object?> get props => [
data,
page,
size,
hasNext,
totalItems,
totalPages,
];
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/device_managment/shared/navigate_home_grid_view.dart';
import 'package:syncrow_web/pages/space_management_v2/main_module/widgets/space_management_body.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/services/debounced_communities_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/data/services/remote_communities_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/theme/responsive_text_theme.dart';
import 'package:syncrow_web/web_layout/web_scaffold.dart';
class SpaceManagementPage extends StatelessWidget {
const SpaceManagementPage({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => CommunitiesBloc(
communitiesService: DebouncedCommunitiesService(
RemoteCommunitiesService(HTTPService()),
),
)..add(const LoadCommunities(LoadCommunitiesParam())),
),
BlocProvider(create: (context) => CommunitiesTreeSelectionBloc()),
],
child: WebScaffold(
appBarTitle: Text(
'Space Management',
style: ResponsiveTextTheme.of(context).deviceManagementTitle,
),
enableMenuSidebar: false,
centerBody: Text(
'Community Structure',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.bold,
),
),
rightBody: const NavigateHomeGridView(),
scaffoldBody: const SpaceManagementBody(),
),
);
}
}

View File

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree.dart';
class SpaceManagementBody extends StatelessWidget {
const SpaceManagementBody({super.key});
@override
Widget build(BuildContext context) {
return const Row(
children: [
SpaceManagementCommunitiesTree(),
],
);
}
}

View File

@ -0,0 +1,42 @@
import 'dart:async';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart';
final class DebouncedCommunitiesService implements CommunitiesService {
DebouncedCommunitiesService(
this._decoratee, {
this.debounceDuration = const Duration(milliseconds: 500),
});
final CommunitiesService _decoratee;
final Duration debounceDuration;
Timer? _debounceTimer;
late Completer<CommunitiesPaginationModel>? _completer;
@override
Future<CommunitiesPaginationModel> getCommunity(
LoadCommunitiesParam param,
) async {
_debounceTimer?.cancel();
_completer = Completer<CommunitiesPaginationModel>();
final currentCompleter = _completer!;
_debounceTimer = Timer(debounceDuration, () async {
try {
final result = await _decoratee.getCommunity(param);
if (!currentCompleter.isCompleted) {
currentCompleter.complete(result);
}
} catch (error) {
if (!currentCompleter.isCompleted) {
currentCompleter.completeError(error);
}
}
});
return currentCompleter.future;
}
}

View File

@ -1,9 +1,11 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/services/communities_service.dart';
import 'package:syncrow_web/services/api/api_exception.dart';
import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/constants/api_const.dart';
class RemoteCommunitiesService implements CommunitiesService {
const RemoteCommunitiesService(this._httpService);
@ -13,14 +15,26 @@ class RemoteCommunitiesService implements CommunitiesService {
static const _defaultErrorMessage = 'Failed to load communities';
@override
Future<List<CommunityModel>> getCommunity(LoadCommunitiesParam param) async {
Future<CommunitiesPaginationModel> getCommunity(
LoadCommunitiesParam param,
) async {
try {
return _httpService.get(
path: '/api/communities/',
expectedResponseModel: (json) => (json as List<dynamic>)
.map((e) => CommunityModel.fromJson(e as Map<String, dynamic>))
.toList(),
final response = await _httpService.get(
path: await _makeUrl(),
queryParameters: {
'page': param.page,
'size': param.size,
'includeSpaces': param.includeSpaces,
if (param.search.isNotEmpty && param.search != 'null')
'search': param.search,
},
expectedResponseModel: (json) => CommunitiesPaginationModel.fromJson(
json as Map<String, dynamic>,
CommunityModel.fromJsonList,
),
);
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
@ -31,4 +45,13 @@ class RemoteCommunitiesService implements CommunitiesService {
throw APIException(formattedErrorMessage);
}
}
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null) throw APIException('Project UUID is required');
return ApiEndpoints.getCommunityListv2.replaceAll(
'{projectId}',
projectUuid,
);
}
}

View File

@ -4,11 +4,19 @@ import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain
class CommunityModel extends Equatable {
final String uuid;
final String name;
final DateTime createdAt;
final DateTime updatedAt;
final String description;
final String externalId;
final List<SpaceModel> spaces;
const CommunityModel({
required this.uuid,
required this.name,
required this.createdAt,
required this.updatedAt,
required this.description,
required this.externalId,
required this.spaces,
});
@ -16,11 +24,20 @@ class CommunityModel extends Equatable {
return CommunityModel(
uuid: json['uuid'] as String,
name: json['name'] as String,
spaces: (json['spaces'] as List<dynamic>)
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
description: json['description'] as String,
externalId: json['externalId']?.toString() ?? '',
spaces: (json['spaces'] as List<dynamic>? ?? <dynamic>[])
.map((e) => SpaceModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
static List<CommunityModel> fromJsonList(List<dynamic> json) {
return json
.map((e) => CommunityModel.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
List<Object?> get props => [uuid, name, spaces];

View File

@ -2,26 +2,37 @@ import 'package:equatable/equatable.dart';
class SpaceModel extends Equatable {
final String uuid;
final DateTime? createdAt;
final DateTime? updatedAt;
final String spaceName;
final String icon;
final List<SpaceModel> children;
final SpaceModel? parent;
const SpaceModel({
required this.uuid,
required this.createdAt,
required this.updatedAt,
required this.spaceName,
required this.icon,
required this.children,
required this.parent,
});
factory SpaceModel.fromJson(Map<String, dynamic> json) {
return SpaceModel(
uuid: json['uuid'] as String,
spaceName: json['spaceName'] as String,
icon: json['icon'] as String,
uuid: json['uuid'] as String? ?? '',
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
spaceName: json['spaceName'] as String? ?? '',
icon: json['icon'] as String? ?? 'assets/icons/location_icon.svg',
children: (json['children'] as List<dynamic>?)
?.map((e) => SpaceModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
parent: json['parent'] != null
? SpaceModel.fromJson(json['parent'] as Map<String, dynamic>)
: null,
);
}

View File

@ -1,3 +1,32 @@
class LoadCommunitiesParam {
const LoadCommunitiesParam();
import 'package:equatable/equatable.dart';
class LoadCommunitiesParam extends Equatable {
const LoadCommunitiesParam({
this.page = 1,
this.size = 25,
this.search = '',
this.includeSpaces = true,
});
final int page;
final int size;
final String search;
final bool includeSpaces;
LoadCommunitiesParam copyWith({
int? page,
int? size,
String? search,
bool? includeSpaces,
}) {
return LoadCommunitiesParam(
page: page ?? this.page,
size: size ?? this.size,
search: search ?? this.search,
includeSpaces: includeSpaces ?? this.includeSpaces,
);
}
@override
List<Object?> get props => [page, size, search, includeSpaces];
}

View File

@ -1,6 +1,9 @@
import 'package:syncrow_web/pages/space_management_v2/main_module/shared/models/paginated_data_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
typedef CommunitiesPaginationModel = PaginatedDataModel<CommunityModel>;
abstract class CommunitiesService {
Future<List<CommunityModel>> getCommunity(LoadCommunitiesParam param);
Future<CommunitiesPaginationModel> getCommunity(LoadCommunitiesParam param);
}

View File

@ -14,6 +14,8 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
}) : _communitiesService = communitiesService,
super(const CommunitiesState()) {
on<LoadCommunities>(_onLoadCommunities);
on<LoadMoreCommunities>(_onLoadMoreCommunities);
on<InsertCommunity>(_onInsertCommunity);
}
final CommunitiesService _communitiesService;
@ -23,28 +25,93 @@ class CommunitiesBloc extends Bloc<CommunitiesEvent, CommunitiesState> {
Emitter<CommunitiesState> emit,
) async {
try {
emit(const CommunitiesState(status: CommunitiesStatus.loading));
final communities = await _communitiesService.getCommunity(event.param);
emit(
state.copyWith(status: CommunitiesStatus.loading),
);
final paginationResponse = await _communitiesService.getCommunity(
event.param,
);
emit(
CommunitiesState(
status: CommunitiesStatus.success,
communities: communities,
communities: paginationResponse.data,
hasNext: paginationResponse.hasNext,
currentPage: paginationResponse.page,
searchQuery: event.param.search,
isLoadingMore: false,
),
);
} on APIException catch (e) {
_onApiException(e, emit);
} catch (e) {
_onError(e, emit);
}
}
Future<void> _onLoadMoreCommunities(
LoadMoreCommunities event,
Emitter<CommunitiesState> emit,
) async {
if (!state.hasNext || state.isLoadingMore) return;
try {
emit(state.copyWith(isLoadingMore: true));
final param = LoadCommunitiesParam(
page: state.currentPage + 1,
search: state.searchQuery,
);
final paginationResponse = await _communitiesService.getCommunity(param);
final updatedCommunities = List<CommunityModel>.from(state.communities)
..addAll(paginationResponse.data);
emit(
CommunitiesState(
state.copyWith(
status: CommunitiesStatus.success,
communities: updatedCommunities,
hasNext: paginationResponse.hasNext,
currentPage: paginationResponse.page,
isLoadingMore: false,
),
);
} on APIException catch (e) {
_onApiException(e, emit);
} catch (e) {
_onError(e, emit);
}
}
void _onApiException(
APIException e,
Emitter<CommunitiesState> emit,
) {
emit(
state.copyWith(
status: CommunitiesStatus.failure,
isLoadingMore: false,
errorMessage: e.message,
),
);
} catch (e) {
}
void _onError(Object e, Emitter<CommunitiesState> emit) {
emit(
CommunitiesState(
state.copyWith(
status: CommunitiesStatus.failure,
isLoadingMore: false,
errorMessage: e.toString(),
),
);
}
void _onInsertCommunity(
InsertCommunity event,
Emitter<CommunitiesState> emit,
) {
emit(state.copyWith(communities: [event.community, ...state.communities]));
}
}

View File

@ -15,3 +15,19 @@ class LoadCommunities extends CommunitiesEvent {
@override
List<Object?> get props => [param];
}
class LoadMoreCommunities extends CommunitiesEvent {
const LoadMoreCommunities();
@override
List<Object?> get props => [];
}
final class InsertCommunity extends CommunitiesEvent {
const InsertCommunity(this.community);
final CommunityModel community;
@override
List<Object?> get props => [community];
}

View File

@ -7,12 +7,48 @@ final class CommunitiesState extends Equatable {
this.status = CommunitiesStatus.initial,
this.communities = const [],
this.errorMessage,
this.isLoadingMore = false,
this.hasNext = false,
this.currentPage = 1,
this.searchQuery = '',
});
final CommunitiesStatus status;
final List<CommunityModel> communities;
final String? errorMessage;
final bool isLoadingMore;
final bool hasNext;
final int currentPage;
final String searchQuery;
CommunitiesState copyWith({
CommunitiesStatus? status,
List<CommunityModel>? communities,
String? errorMessage,
bool? isLoadingMore,
bool? hasNext,
int? currentPage,
String? searchQuery,
}) {
return CommunitiesState(
status: status ?? this.status,
communities: communities ?? this.communities,
errorMessage: errorMessage ?? this.errorMessage,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasNext: hasNext ?? this.hasNext,
currentPage: currentPage ?? this.currentPage,
searchQuery: searchQuery ?? this.searchQuery,
);
}
@override
List<Object?> get props => [status, communities, errorMessage];
List<Object?> get props => [
status,
communities,
errorMessage,
isLoadingMore,
hasNext,
currentPage,
searchQuery,
];
}

View File

@ -0,0 +1,47 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
part 'communities_tree_selection_event.dart';
part 'communities_tree_selection_state.dart';
class CommunitiesTreeSelectionBloc
extends Bloc<CommunitiesTreeSelectionEvent, CommunitiesTreeSelectionState> {
CommunitiesTreeSelectionBloc() : super(const CommunitiesTreeSelectionState()) {
on<SelectCommunityEvent>(_onSelectCommunity);
on<SelectSpaceEvent>(_onSelectSpace);
on<ClearCommunitiesTreeSelectionEvent>(_onClearSelection);
}
void _onSelectCommunity(
SelectCommunityEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(
CommunitiesTreeSelectionState(
selectedCommunity: event.community,
selectedSpace: null,
),
);
}
void _onSelectSpace(
SelectSpaceEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(
CommunitiesTreeSelectionState(
selectedCommunity: null,
selectedSpace: event.space,
),
);
}
void _onClearSelection(
ClearCommunitiesTreeSelectionEvent event,
Emitter<CommunitiesTreeSelectionState> emit,
) {
emit(const CommunitiesTreeSelectionState());
}
}

View File

@ -0,0 +1,30 @@
part of 'communities_tree_selection_bloc.dart';
sealed class CommunitiesTreeSelectionEvent extends Equatable {
const CommunitiesTreeSelectionEvent();
@override
List<Object?> get props => [];
}
final class SelectCommunityEvent extends CommunitiesTreeSelectionEvent {
final CommunityModel? community;
const SelectCommunityEvent({required this.community});
@override
List<Object?> get props => [community];
}
final class SelectSpaceEvent extends CommunitiesTreeSelectionEvent {
final SpaceModel? space;
const SelectSpaceEvent({required this.space});
@override
List<Object?> get props => [space];
}
final class ClearCommunitiesTreeSelectionEvent
extends CommunitiesTreeSelectionEvent {
const ClearCommunitiesTreeSelectionEvent();
}

View File

@ -0,0 +1,29 @@
part of 'communities_tree_selection_bloc.dart';
final class CommunitiesTreeSelectionState extends Equatable {
const CommunitiesTreeSelectionState({
this.selectedCommunity,
this.selectedSpace,
});
final CommunityModel? selectedCommunity;
final SpaceModel? selectedSpace;
CommunitiesTreeSelectionState copyWith({
CommunityModel? selectedCommunity,
SpaceModel? selectedSpace,
List<CommunityModel>? expandedCommunities,
List<SpaceModel>? expandedSpaces,
}) {
return CommunitiesTreeSelectionState(
selectedCommunity: selectedCommunity ?? this.selectedCommunity,
selectedSpace: selectedSpace ?? this.selectedSpace,
);
}
@override
List<Object?> get props => [
selectedCommunity,
selectedSpace,
];
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
class CommunitiesTreeFailureWidget extends StatelessWidget {
const CommunitiesTreeFailureWidget({super.key, this.errorMessage});
final String? errorMessage;
@override
Widget build(BuildContext context) {
return Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
errorMessage ?? 'Something went wrong',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<CommunitiesBloc>().add(
LoadCommunities(
LoadCommunitiesParam(
search: context.read<CommunitiesBloc>().state.searchQuery,
),
),
),
child: const Text('Retry'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class CommunityTile extends StatelessWidget {
final String title;
final List<Widget>? children;
final bool isExpanded;
final bool isSelected;
final void Function(String, bool isExpanded) onExpansionChanged;
final void Function() onItemSelected;
const CommunityTile({
super.key,
required this.title,
required this.isExpanded,
required this.onExpansionChanged,
required this.onItemSelected,
required this.isSelected,
this.children,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: CustomExpansionTile(
title: title,
initiallyExpanded: isExpanded,
isSelected: isSelected,
onExpansionChanged: (bool expanded) {
onExpansionChanged(title, expanded);
},
onItemSelected: onItemSelected,
children: children ?? [],
));
}
}

View File

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class EmptyCommunitiesTreeSearchResultWidget extends StatelessWidget {
const EmptyCommunitiesTreeSearchResultWidget({
required this.searchQuery,
super.key,
});
final String searchQuery;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
searchQuery.isEmpty
? 'No communities found'
: 'No communities found for "$searchQuery"',
textAlign: TextAlign.center,
),
);
}
}

View File

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/widgets/app_loading_indicator.dart';
import 'package:syncrow_web/common/widgets/search_bar.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/params/load_communities_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/communities_tree_failure_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/empty_communities_tree_search_result_widget.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_community_tile.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_communities_list.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_header.dart';
import 'package:syncrow_web/utils/style.dart';
class SpaceManagementCommunitiesTree extends StatefulWidget {
const SpaceManagementCommunitiesTree({super.key});
@override
State<SpaceManagementCommunitiesTree> createState() =>
_SpaceManagementCommunitiesTreeState();
}
class _SpaceManagementCommunitiesTreeState
extends State<SpaceManagementCommunitiesTree> {
@override
void initState() {
context.read<CommunitiesBloc>().add(
const LoadCommunities(LoadCommunitiesParam()),
);
super.initState();
}
void _onSearchChanged(String searchQuery) {
context
.read<CommunitiesBloc>()
.add(LoadCommunities(LoadCommunitiesParam(search: searchQuery.trim())));
}
void _onLoadMore() {
context.read<CommunitiesBloc>().add(const LoadMoreCommunities());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CommunitiesBloc, CommunitiesState>(
builder: (context, state) => Container(
width: 320,
decoration: subSectionContainerDecoration,
child: Column(
children: [
const SpaceManagementSidebarHeader(),
CustomSearchBar(
onSearchChanged: _onSearchChanged,
),
const SizedBox(height: 16),
switch (state.status) {
CommunitiesStatus.initial => const AppLoadingIndicator(),
CommunitiesStatus.loading => state.communities.isEmpty
? const AppLoadingIndicator()
: _buildCommunitiesTree(context, state),
CommunitiesStatus.success => _buildCommunitiesTree(context, state),
CommunitiesStatus.failure => CommunitiesTreeFailureWidget(
errorMessage: state.errorMessage,
),
},
Visibility(
visible: state.isLoadingMore,
child: const AppLoadingIndicator(),
),
],
),
),
);
}
Widget _buildCommunitiesTree(
BuildContext context,
CommunitiesState state,
) {
final communitiesIsEmpty = state.communities.isEmpty;
final statusIsSuccess = state.status == CommunitiesStatus.success;
return Expanded(
child: Visibility(
visible: statusIsSuccess && communitiesIsEmpty,
replacement: Stack(
children: [
SpaceManagementSidebarCommunitiesList(
communities: state.communities,
onLoadMore: state.hasNext ? _onLoadMore : null,
isLoadingMore: state.isLoadingMore,
hasNext: state.hasNext,
itemBuilder: (context, index) {
return SpaceManagementCommunitiesTreeCommunityTile(
community: state.communities[index],
);
},
),
if (state.status == CommunitiesStatus.loading &&
state.communities.isNotEmpty)
ColoredBox(
color: Colors.white.withValues(alpha: 0.7),
child: const AppLoadingIndicator(),
),
],
),
child: EmptyCommunitiesTreeSearchResultWidget(
searchQuery: state.searchQuery,
),
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/community_tile.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_communities_tree_space_tile.dart';
class SpaceManagementCommunitiesTreeCommunityTile extends StatelessWidget {
const SpaceManagementCommunitiesTreeCommunityTile({
required this.community,
super.key,
});
final CommunityModel community;
@override
Widget build(BuildContext context) {
final spaces = community.spaces
.map(
(space) => SpaceManagementCommunitiesTreeSpaceTile(
space: space,
community: community,
),
)
.toList();
return CommunityTile(
title: community.name,
key: ValueKey(community.uuid),
isSelected: context
.watch<CommunitiesTreeSelectionBloc>()
.state
.selectedCommunity
?.uuid ==
community.uuid,
isExpanded: false,
onItemSelected: () {
context.read<CommunitiesTreeSelectionBloc>().add(
SelectCommunityEvent(community: community),
);
},
onExpansionChanged: (title, expanded) {},
children: spaces,
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_tile.dart';
class SpaceManagementCommunitiesTreeSpaceTile extends StatelessWidget {
const SpaceManagementCommunitiesTreeSpaceTile({
required this.space,
required this.community,
super.key,
});
final SpaceModel space;
final CommunityModel community;
@override
Widget build(BuildContext context) {
final spaceIsExpanded = _isSpaceOrChildSelected(context, space);
final isSelected =
context.watch<CommunitiesTreeSelectionBloc>().state.selectedSpace?.uuid ==
space.uuid;
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16.0),
child: SpaceTile(
title: space.spaceName,
key: ValueKey(space.uuid),
isSelected: isSelected,
initiallyExpanded: spaceIsExpanded,
onExpansionChanged: (expanded) {},
onItemSelected: () => context.read<CommunitiesTreeSelectionBloc>().add(
SelectSpaceEvent(space: space),
),
children: space.children
.map(
(childSpace) => SpaceManagementCommunitiesTreeSpaceTile(
space: childSpace,
community: community,
),
)
.toList(),
),
);
}
bool _isSpaceOrChildSelected(BuildContext context, SpaceModel space) {
final selectedSpace =
context.read<CommunitiesTreeSelectionBloc>().state.selectedSpace;
final isSpaceSelected = selectedSpace?.uuid == space.uuid;
final anySubSpaceIsSelected = space.children.any(
(child) => _isSpaceOrChildSelected(context, child),
);
return isSpaceSelected || anySubSpaceIsSelected;
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/constants/assets.dart';
class SpaceManagementSidebarAddCommunityButton extends StatelessWidget {
const SpaceManagementSidebarAddCommunityButton({
required this.onTap,
super.key,
});
final void Function() onTap;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 30,
child: IconButton(
style: IconButton.styleFrom(
iconSize: 20,
backgroundColor: ColorsManager.circleImageBackground,
shape: const CircleBorder(
side: BorderSide(
color: ColorsManager.lightGrayBorderColor,
width: 3,
),
),
),
onPressed: onTap,
icon: SvgPicture.asset(Assets.addIcon),
),
);
}
}

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class SpaceManagementSidebarCommunitiesList extends StatefulWidget {
const SpaceManagementSidebarCommunitiesList({
required this.communities,
required this.itemBuilder,
this.onLoadMore,
this.isLoadingMore = false,
this.hasNext = false,
super.key,
});
final List<CommunityModel> communities;
final Widget Function(BuildContext context, int index) itemBuilder;
final VoidCallback? onLoadMore;
final bool isLoadingMore;
final bool hasNext;
@override
State<SpaceManagementSidebarCommunitiesList> createState() =>
_SpaceManagementSidebarCommunitiesListState();
}
class _SpaceManagementSidebarCommunitiesListState
extends State<SpaceManagementSidebarCommunitiesList> {
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100) {
if (widget.hasNext && !widget.isLoadingMore && widget.onLoadMore != null) {
widget.onLoadMore!();
}
}
}
bool _onNotification(ScrollEndNotification notification) {
final hasReachedEnd = notification.metrics.extentAfter == 0;
if (hasReachedEnd &&
widget.hasNext &&
!widget.isLoadingMore &&
widget.onLoadMore != null) {
widget.onLoadMore!();
return true;
}
return false;
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final itemCount = widget.communities.length + (widget.isLoadingMore ? 1 : 0);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: context.screenWidth * 0.5,
child: Scrollbar(
scrollbarOrientation: ScrollbarOrientation.left,
thumbVisibility: true,
controller: _scrollController,
child: NotificationListener<ScrollEndNotification>(
onNotification: _onNotification,
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsetsDirectional.only(start: 16),
itemCount: itemCount,
controller: _scrollController,
itemBuilder: (context, index) {
if (index == widget.communities.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
return widget.itemBuilder(context, index);
},
),
),
),
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/bloc/communities_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/communities_tree_selection_bloc/communities_tree_selection_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/presentation/widgets/space_management_sidebar_add_community_button.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
import 'package:syncrow_web/utils/style.dart';
class SpaceManagementSidebarHeader extends StatelessWidget {
const SpaceManagementSidebarHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: subSectionContainerDecoration,
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Communities',
style: context.textTheme.titleMedium?.copyWith(
color: ColorsManager.blackColor,
),
),
SpaceManagementSidebarAddCommunityButton(
onTap: () => _onAddCommunity(context),
),
],
),
);
}
void _onAddCommunity(BuildContext context) {
final bloc = context.read<CommunitiesTreeSelectionBloc>();
final selectedCommunity = bloc.state.selectedCommunity;
final isSelected = selectedCommunity?.uuid.isNotEmpty ?? false;
if (isSelected) {
_clearSelection(context);
} else {
_showCreateCommunityDialog(context);
}
}
void _clearSelection(BuildContext context) {
context.read<CommunitiesTreeSelectionBloc>().add(
const ClearCommunitiesTreeSelectionEvent(),
);
}
void _showCreateCommunityDialog(BuildContext context) => showDialog<void>(
context: context,
builder: (_) => CreateCommunityDialog(
title: const Text('Community Name'),
onCreateCommunity: (community) {
context.read<CommunitiesBloc>().add(
InsertCommunity(community),
);
context.read<CommunitiesTreeSelectionBloc>().add(
SelectCommunityEvent(community: community),
);
},
),
);
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class SpaceTile extends StatefulWidget {
final String title;
final bool isSelected;
final bool initiallyExpanded;
final ValueChanged<bool> onExpansionChanged;
final List<Widget>? children;
final void Function() onItemSelected;
const SpaceTile({
super.key,
required this.title,
required this.initiallyExpanded,
required this.onExpansionChanged,
required this.onItemSelected,
required this.isSelected,
this.children,
});
@override
State<SpaceTile> createState() => _SpaceTileState();
}
class _SpaceTileState extends State<SpaceTile> {
late bool _isExpanded;
@override
void initState() {
super.initState();
_isExpanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0),
child: CustomExpansionTile(
isSelected: widget.isSelected,
title: widget.title,
initiallyExpanded: _isExpanded,
onItemSelected: widget.onItemSelected,
onExpansionChanged: (bool expanded) {
setState(() {
_isExpanded = expanded;
});
widget.onExpansionChanged(expanded);
},
children: widget.children ?? [],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:syncrow_web/pages/common/bloc/project_manager.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/services/create_community_service.dart';
@ -16,24 +17,51 @@ class RemoteCreateCommunityService implements CreateCommunityService {
Future<CommunityModel> createCommunity(CreateCommunityParam param) async {
try {
final response = await _httpService.post(
path: 'endpoint',
expectedResponseModel: (data) => CommunityModel.fromJson(
data as Map<String, dynamic>,
),
path: await _makeUrl(),
body: {
'name': param.name,
'description': param.description,
},
expectedResponseModel: (data) {
final json = data as Map<String, dynamic>;
if (json['success'] == true) {
return CommunityModel.fromJson(
json['data'] as Map<String, dynamic>,
);
}
return null;
},
);
if (response == null) {
throw APIException(
_getErrorMessageFromBody(response as Map<String, dynamic>?),
);
}
return response;
} on DioException catch (e) {
final message = e.response?.data as Map<String, dynamic>?;
final error = message?['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
final formattedErrorMessage = [
_defaultErrorMessage,
errorMessage,
].join(': ');
throw APIException(formattedErrorMessage);
throw APIException(_getErrorMessageFromBody(message));
} catch (e) {
final formattedErrorMessage = [_defaultErrorMessage, '$e'].join(': ');
throw APIException(formattedErrorMessage);
}
}
String _getErrorMessageFromBody(Map<String, dynamic>? body) {
if (body == null) {
return _defaultErrorMessage;
}
final error = body['error'] as Map<String, dynamic>?;
final errorMessage = error?['error'] as String? ?? '';
return errorMessage;
}
Future<String> _makeUrl() async {
final projectUuid = await ProjectManager.getProjectUUID();
if (projectUuid == null) {
throw APIException('Project UUID is not set');
}
return '/projects/$projectUuid/communities';
}
}

View File

@ -1,9 +1,13 @@
import 'package:equatable/equatable.dart';
class CreateCommunityParam extends Equatable {
const CreateCommunityParam({required this.name});
const CreateCommunityParam({
required this.name,
this.description = '',
});
final String name;
final String description;
@override
List<Object> get props => [name];

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/data/services/remote_create_community_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_dialog_widget.dart';
import 'package:syncrow_web/services/api/http_service.dart';
class CreateCommunityDialog extends StatelessWidget {
final void Function(CommunityModel community) onCreateCommunity;
final String? initialName;
final Widget title;
const CreateCommunityDialog({
super.key,
required this.onCreateCommunity,
required this.title,
this.initialName,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CreateCommunityBloc(RemoteCreateCommunityService(HTTPService())),
child: BlocListener<CreateCommunityBloc, CreateCommunityState>(
listener: (context, state) {
switch (state) {
case CreateCommunityLoading():
showDialog<void>(
context: context,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
break;
case CreateCommunitySuccess(:final community):
Navigator.of(context).pop();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Community created successfully')),
);
onCreateCommunity.call(community);
break;
case CreateCommunityFailure(:final message):
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
break;
default:
break;
}
},
child: CreateCommunityDialogWidget(
title: title,
initialName: initialName,
),
),
);
}
}

View File

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/common/buttons/cancel_button.dart';
import 'package:syncrow_web/pages/common/buttons/default_button.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/domain/param/create_community_param.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/bloc/create_community_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/create_community/presentation/create_community_name_text_field.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class CreateCommunityDialogWidget extends StatefulWidget {
final String? initialName;
final Widget title;
const CreateCommunityDialogWidget({
super.key,
required this.title,
this.initialName,
});
@override
State<CreateCommunityDialogWidget> createState() =>
_CreateCommunityDialogWidgetState();
}
class _CreateCommunityDialogWidgetState extends State<CreateCommunityDialogWidget> {
late final TextEditingController _nameController;
@override
void initState() {
_nameController = TextEditingController(text: widget.initialName ?? '');
super.initState();
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: ColorsManager.transparentColor,
child: Container(
width: MediaQuery.of(context).size.width * 0.3,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorsManager.whiteColors,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: ColorsManager.blackColor.withValues(alpha: 0.25),
blurRadius: 20,
spreadRadius: 5,
offset: const Offset(0, 5),
),
],
),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: BlocBuilder<CreateCommunityBloc, CreateCommunityState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DefaultTextStyle(
style: Theme.of(context).textTheme.headlineMedium!,
child: widget.title,
),
const SizedBox(height: 18),
CreateCommunityNameTextField(
nameController: _nameController,
),
if (state case CreateCommunityFailure(:final message))
Padding(
padding: const EdgeInsets.only(top: 18),
child: SelectableText(
'* $message',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 24),
_buildActionButtons(context),
],
);
},
),
),
),
),
);
}
Widget _buildActionButtons(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: CancelButton(
label: 'Cancel',
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(width: 16),
_buildCreateCommunityButton(context),
],
);
}
Widget _buildCreateCommunityButton(BuildContext context) {
return Expanded(
child: DefaultButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
_onSubmit(context);
}
},
borderRadius: 10,
foregroundColor: ColorsManager.whiteColors,
child: const Text('OK'),
),
);
}
void _onSubmit(BuildContext context) {
if (_formKey.currentState?.validate() ?? false) {
context.read<CreateCommunityBloc>().add(
CreateCommunity(
CreateCommunityParam(
name: _nameController.text.trim(),
),
),
);
}
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
class CreateCommunityNameTextField extends StatelessWidget {
const CreateCommunityNameTextField({
required this.nameController,
super.key,
});
final TextEditingController nameController;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: nameController,
validator: _validator,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the community name',
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(ColorsManager.boxColor),
focusedBorder: _buildBorder(),
focusedErrorBorder: _buildBorder(Theme.of(context).colorScheme.error),
errorBorder: _buildBorder(Theme.of(context).colorScheme.error),
),
);
}
String? _validator(String? value) {
if (value == null || value.isEmpty) {
return '*Name should not be empty.';
}
return null;
}
InputBorder _buildBorder([Color? color]) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: color ?? ColorsManager.vividBlue.withValues(alpha: 0.5),
width: 1,
),
);
}
}

View File

@ -4,11 +4,10 @@ import 'package:syncrow_web/common/widgets/custom_expansion_tile.dart';
class SpaceTile extends StatefulWidget {
final String title;
final bool isSelected;
final bool initiallyExpanded;
final ValueChanged<bool> onExpansionChanged;
final List<Widget>? children;
final Function() onItemSelected;
final void Function() onItemSelected;
const SpaceTile({
super.key,

View File

@ -3,14 +3,20 @@ import 'package:syncrow_web/services/api/http_service.dart';
import 'package:syncrow_web/utils/constants/api_const.dart';
class HomeApi {
Future fetchUserInfo(userId) async {
Future<UserModel?> fetchUserInfo(String userId) async {
try {
final response = await HTTPService().get(
path: ApiEndpoints.getUser.replaceAll('{userUuid}', userId!),
path: ApiEndpoints.getUser.replaceAll('{userUuid}', userId),
showServerMessage: true,
expectedResponseModel: (json) {
return UserModel.fromJson(json);
});
final user = UserModel.fromJson(json as Map<String, dynamic>);
return user;
},
);
return response;
} catch (e) {
return null;
}
}
Future fetchTerms() async {

View File

@ -34,9 +34,8 @@ class UserPermissionApi {
path: ApiEndpoints.roleTypes,
showServerMessage: true,
expectedResponseModel: (json) {
final List<RoleTypeModel> fetchedRoles = (json['data'] as List)
.map((item) => RoleTypeModel.fromJson(item))
.toList();
final List<RoleTypeModel> fetchedRoles =
(json['data'] as List).map((item) => RoleTypeModel.fromJson(item)).toList();
return fetchedRoles;
},
);
@ -48,9 +47,7 @@ class UserPermissionApi {
path: ApiEndpoints.permission.replaceAll("roleUuid", roleUuid),
showServerMessage: true,
expectedResponseModel: (json) {
return (json as List)
.map((data) => PermissionOption.fromJson(data))
.toList();
return (json as List).map((data) => PermissionOption.fromJson(data)).toList();
},
);
return response ?? [];
@ -195,14 +192,10 @@ class UserPermissionApi {
Future<bool> changeUserStatusById(userUuid, status, String projectUuid) async {
try {
Map<String, dynamic> bodya = {
"disable": status,
"projectUuid": projectUuid
};
Map<String, dynamic> bodya = {"disable": status, "projectUuid": projectUuid};
final response = await _httpService.put(
path: ApiEndpoints.changeUserStatus
.replaceAll("{invitedUserUuid}", userUuid),
path: ApiEndpoints.changeUserStatus.replaceAll("{invitedUserUuid}", userUuid),
body: bodya,
expectedResponseModel: (json) {
return json['success'];

View File

@ -1,7 +1,7 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
abstract class ApiEndpoints {
static const String projectUuid = "0e62577c-06fa-41b9-8a92-99a21fbaf51c";
static const String projectUuid = "bcda711e-9fc2-4168-a05e-171b4026d1ff";
static String baseUrl = dotenv.env['BASE_URL'] ?? '';
static const String signUp = '/authentication/user/signup';
static const String login = '/authentication/user/login';
@ -46,6 +46,7 @@ abstract class ApiEndpoints {
// Community Module
static const String createCommunity = '/projects/{projectId}/communities';
static const String getCommunityList = '/projects/{projectId}/communities';
static const String getCommunityListv2 = '/projects/{projectId}/communities/v2';
static const String getCommunityById =
'/projects/{projectId}/communities/{communityId}';
static const String updateCommunity =

View File

@ -1,3 +1,3 @@
class TempConst {
static const projectId = '0e62577c-06fa-41b9-8a92-99a21fbaf51c';
static const projectId = 'bcda711e-9fc2-4168-a05e-171b4026d1ff';
}