diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index b9789c2a3..36f3bd48f 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -6,7 +6,7 @@ updates: directory: "/" # Check the pub.dev registry for updates every day (weekdays) schedule: - interval: "weekly" + interval: "monthly" target-branch: "develop" # # Add default reviewers # reviewers: diff --git a/.github/workflows/authorized-changes-detection.yml b/.github/workflows/authorized-changes-detection.yml deleted file mode 100644 index 1ccac228e..000000000 --- a/.github/workflows/authorized-changes-detection.yml +++ /dev/null @@ -1,31 +0,0 @@ -############################################################################## -############################################################################## -# -# NOTE! -# -# Please read the README.md file in this directory that defines what should -# be placed in this file -# -############################################################################## -############################################################################## - -name: Checking workflow files -on: - pull_request: - paths: - - '.github/**' - - 'analysis_options.yaml' - - 'pubspec.yaml' - - 'pubspec.lock' - - 'lib/main.dart' - - 'CODEOWNERS' - - 'LICENSE' - -jobs: - Checking-for-unauthorized-file-changes: - name: Checking for unauthorized file changes - runs-on: ubuntu-latest - - steps: - - name: Unauthorized file modification in PR - run: exit 1 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 53414e5ad..be05c8718 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -48,7 +48,7 @@ jobs: - name: Count lines of code in each file run: chmod +x ./.github/workflows/countline.py - name: Running count lines - run: ./.github/workflows/countline.py --exclude_directories test/ --exclude_files lib/custom_painters/talawa_logo.dart lib/custom_painters/language_icon.dart lib/custom_painters/whatsapp_logo.dart lib/utils/queries.dart lib/view_model/after_auth_view_models/profile_view_models/profile_page_view_model.dart lib/view_model/pre_auth_view_models/select_organization_view_model.dart lib/views/after_auth_screens/profile/profile_page.dart lib/view_model/main_screen_view_model.dart lib/views/after_auth_screens/events/create_event_page.dart lib/views/after_auth_screens/org_info_screen.dart lib/views/after_auth_screens/events/manage_volunteer_group.dart + run: ./.github/workflows/countline.py --exclude_directories test/ --exclude_files lib/custom_painters/talawa_logo.dart lib/custom_painters/language_icon.dart lib/custom_painters/whatsapp_logo.dart lib/utils/queries.dart lib/view_model/after_auth_view_models/profile_view_models/profile_page_view_model.dart lib/view_model/pre_auth_view_models/select_organization_view_model.dart lib/views/after_auth_screens/profile/profile_page.dart lib/view_model/main_screen_view_model.dart lib/views/after_auth_screens/events/create_event_page.dart lib/views/after_auth_screens/org_info_screen.dart lib/views/after_auth_screens/events/manage_volunteer_group.dart lib/views/after_auth_screens/events/create_agenda_item_page.dart lib/views/after_auth_screens/events/edit_agenda_item_page.dart lib/utils/event_queries.dart - name: setup python uses: actions/setup-python@v5 - name: Check for presence of ignore directives corresponding to custom lints @@ -173,6 +173,7 @@ jobs: run: flutter build ios --release --no-codesign Branch-check: + if: ${{ github.actor != 'dependabot[bot]' && !contains(github.event.pull_request.labels.*.name, 'ignore-sensitive-files-pr') }} name: "Base branch check" runs-on: ubuntu-latest steps: @@ -182,8 +183,9 @@ jobs: echo "PR is not against develop branch. Please refer PR_GUIDELINES.md" exit 1 - Check-Unauthorized-Changes: - name: Checks if no unauthorized files are changed + Check-Sensitive-Files: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Checks if sensitive files have been changed without authorization runs-on: ubuntu-latest steps: - name: Checkout code @@ -211,6 +213,14 @@ jobs: LICENSE setup.ts .coderabbit.yaml + CODE_OF_CONDUCT.md + CONTRIBUTING.md + DOCUMENTATION.md + INSTALLATION.md + ISSUE_GUIDELINES.md + PR_GUIDELINES.md + README.md + - name: List all changed unauthorized files if: steps.changed-unauth-files.outputs.any_changed == 'true' || steps.changed-unauth-files.outputs.any_deleted == 'true' env: @@ -222,6 +232,7 @@ jobs: exit 1 Count-Changed-Files: + if: ${{ github.actor != 'dependabot[bot]' }} name: Checks if number of files changed is acceptable runs-on: ubuntu-latest steps: diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yml similarity index 66% rename from .github/workflows/push.yaml rename to .github/workflows/push.yml index 22e0e6d0d..a48b221fa 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yml @@ -27,7 +27,8 @@ env: jobs: Flutter-Codebase-Check: - name: Checking codebase + if: ${{ github.actor != 'dependabot[bot]' }} + name: Checking Codebase runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -72,90 +73,9 @@ jobs: # - name: Echo the GitHub context for troubleshooting # run: echo "${{ toJSON(github) }}" - Update-Documentation: - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/automated-docs' - environment: TALAWA_ENVIRONMENT - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 - with: - distribution: 'zulu' # See 'Supported distributions' for available options - java-version: '12.0' - - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.22.3' - channel: 'stable' - - uses: dart-lang/setup-dart@v1 - with: - sdk: '3.4.4' - - run: | - cd talawa_lint && flutter pub get && cd .. - flutter pub get - flutter analyze - dart analyze - flutter pub global activate dartdoc - flutter pub global run dartdoc . --output talawa-mobile-docs --format md --exclude=test/widget_tests/widgets/pinned_carousel_widget_test.dart, lib/widgets/pinned_carousel_widget.dart, lib/widgets/post_widget.dart, test/widget_tests/widgets/post_widget_test.dart - rm -rf talawa-mobile-docs/widgets_pinned_carousel_widget/CustomCarouselScrollerState/build.md - rm -rf talawa-mobile-docs/widgets_post_widget/PostContainerState/build.md - - uses: actions/upload-artifact@v1 - with: - name: talawa-mobile-docs - path: talawa-mobile-docs - - name: Checking doc updated - id: DocUpdated - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "updateDoc=true" >> $GITHUB_OUTPUT - echo -e "Documentation has been updated!!" - else - Green='0;32' - NoColor='\033[0m' - echo -e "${Green}No documentation updated${NoColor}" - fi - - name: Set env variables - if: steps.DocUpdated.outputs.updateDoc - run: | - echo "commit_id=$(echo $(git rev-parse HEAD))" >> $GITHUB_ENV - echo "email=$(echo $(git log --pretty=format:"%ae" $commit_id))" >> $GITHUB_ENV - - name: Handle untracked files - if: steps.DocUpdated.outputs.updateDoc - run: | - git config --global user.name "${{github.actor}}" - git config --global user.email "${{env.email}}" - git add . - - name: Update Doc - if: steps.DocUpdated.outputs.updateDoc - run: | - Green='0;32' - NoColor='\033[0m' - git config --global user.name "${{github.actor}}" - git config --global user.email "${{env.email}}" - git commit -a -m "Updated docs" - git push - echo -e "🚀${Green} Hurrah! doc updated${NoColor}" - - Documentation-to-talawa-docs: - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/automated-docs' - needs: Update-Documentation - steps: - - uses: actions/checkout@v4 - - uses: dmnemec/copy_file_to_another_repo_action@v1.1.1 - env: - API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB_NEW }} - with: - source_file: 'talawa-mobile-docs/' - destination_repo: 'PalisadoesFoundation/talawa-docs' - destination_branch: 'develop' - destination_folder: 'docs' - user_email: '${{env.email}}' - user_name: '${{github.actor}}' - commit_message: 'Overwriting talawa-mobile-docs from talawa-repo' - - Flutter-Testing: + if: ${{ github.actor != 'dependabot[bot]' }} name: Testing codebase runs-on: ubuntu-latest needs: Flutter-Codebase-Check @@ -185,6 +105,7 @@ jobs: name: '${{env.CODECOV_UNIQUE_NAME}}' Android-Build-and-Release: + if: ${{ github.actor != 'dependabot[bot]' }} name: Testing build for android permissions: contents: write @@ -224,6 +145,7 @@ jobs: This may or may not be stable, so please have a look at the stable release(s). iOS-Build: + if: ${{ github.actor != 'dependabot[bot]' }} name: iOS Build and Relaese runs-on: macos-latest needs: Flutter-Testing diff --git a/.github/workflows/talawa_mobile_md_mdx_format_adjuster.py b/.github/workflows/talawa_mobile_md_mdx_format_adjuster.py new file mode 100644 index 000000000..4903fe1f2 --- /dev/null +++ b/.github/workflows/talawa_mobile_md_mdx_format_adjuster.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +""" +Script to adjust Dart documentation for MDX compatibility in Docusaurus. + +This script scans Dart-generated Markdown files and modifies special characters, +code blocks, and Dart-specific symbols to comply with the MDX syntax used in +Docusaurus v3. It ensures compatibility with the markdown processor by making +adjustments like escaping certain characters and modifying code blocks. + +This script complies with: + 1) Pylint + 2) Pydocstyle + 3) Pycodestyle + 4) Flake8 +""" +import os +import argparse +import re + +def escape_mdx_characters(text): + """ + Escape special characters (<, >, {, }) in Dart docs to make them MDX compatible. + + Args: + text (str): The text content to be processed. + + Returns: + str: The modified string with escaped MDX characters. + """ + # Replace unescaped <, >, {, } with their escaped equivalents + patterns = { + "<": r"(?": r"(?", + "{": r"(? setupLocator() async { locator.registerFactory(() => SetUrlViewModel()); locator.registerFactory(() => LoginViewModel()); locator.registerFactory(() => ManageVolunteerGroupViewModel()); + locator.registerFactory(() => EditAgendaItemViewModel()); locator.registerFactory(() => SelectOrganizationViewModel()); locator.registerFactory(() => SignupDetailsViewModel()); locator.registerFactory(() => WaitingViewModel()); diff --git a/lib/models/events/event_agenda_category.dart b/lib/models/events/event_agenda_category.dart new file mode 100644 index 000000000..213cf28c5 --- /dev/null +++ b/lib/models/events/event_agenda_category.dart @@ -0,0 +1,26 @@ +///This class creates an event agenda category model. +class AgendaCategory { + AgendaCategory({ + this.id, + this.name, + this.description, + }); + + /// Creates a new `AgendaCategory` instance from a JSON map. + factory AgendaCategory.fromJson(Map json) { + return AgendaCategory( + id: json['_id'] as String?, + name: json['name'] as String?, + description: json['description'] as String?, + ); + } + + /// Id of the agenda category. + final String? id; + + /// Name of the category. + final String? name; + + /// Description of the category. + final String? description; +} diff --git a/lib/models/events/event_agenda_item.dart b/lib/models/events/event_agenda_item.dart new file mode 100644 index 000000000..f0cd89cf0 --- /dev/null +++ b/lib/models/events/event_agenda_item.dart @@ -0,0 +1,94 @@ +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_model.dart'; +import 'package:talawa/models/organization/org_info.dart'; +import 'package:talawa/models/user/user_info.dart'; + +/// enum for agenda Item type. +enum ItemType { + /// regular type agenda item. + regular, + + /// note type agenda item. + note, +} + +/// Model for Event Agenda Item. +class EventAgendaItem { + EventAgendaItem({ + this.id, + this.title, + this.description, + this.duration, + this.attachments, + this.createdBy, + this.urls, + this.relatedEvent, + this.categories, + this.sequence, + // this.itemType, + // this.isNote, + this.organization, + }); + // Factory constructor for creating an AgendaItem instance from a JSON map + factory EventAgendaItem.fromJson(Map json) { + return EventAgendaItem( + id: json['_id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + duration: json['duration'] as String?, + attachments: (json['attachments'] as List?) + ?.map((e) => e as String) + .toList(), + createdBy: json['createdBy'] != null + ? User.fromJson( + json['createdBy'] as Map, + fromOrg: true, + ) + : null, + urls: (json['urls'] as List?)?.map((e) => e as String).toList(), + relatedEvent: json['relatedEvent'] != null + ? Event.fromJson(json['relatedEvent'] as Map) + : null, + categories: (json['categories'] as List?) + ?.map((e) => AgendaCategory.fromJson(e as Map)) + .toList(), + sequence: json['sequence'] as int?, + organization: json['organization'] != null + ? OrgInfo.fromJson(json['organization'] as Map) + : null, + ); + } + + /// Unique identifier for the agenda item. + final String? id; + + /// Title of the agenda item. + final String? title; + + /// Optional description. + final String? description; + + /// Duration of the agenda item. + final String? duration; + + /// Optional array of attachment URLs. + final List? attachments; + + /// Reference to the user who created the agenda item. + final User? createdBy; + + /// Optional array of URLs related to the agenda item. + final List? urls; + + /// Reference to the event associated with the agenda item. + final Event? relatedEvent; + + /// Optional array of agenda categories. + final List? categories; + + /// Sequence number of the agenda item. + final int? sequence; + + ///Reference to the organization associated with the agenda item. + final OrgInfo? organization; +} diff --git a/lib/services/database_mutation_functions.dart b/lib/services/database_mutation_functions.dart index 48c1ea5a7..583a523b0 100644 --- a/lib/services/database_mutation_functions.dart +++ b/lib/services/database_mutation_functions.dart @@ -6,6 +6,7 @@ import 'package:talawa/locator.dart'; import 'package:talawa/models/organization/org_info.dart'; import 'package:talawa/utils/post_queries.dart'; import 'package:talawa/utils/queries.dart'; +import 'package:talawa/utils/time_conversion.dart'; /// DataBaseMutationFunctions class provides different services that are under the context of graphQL mutations and queries. /// @@ -97,6 +98,13 @@ class DataBaseMutationFunctions { return await gqlAuthQuery(query, variables: variables); } } else if (result.data != null && result.isConcrete) { + // coverage:ignore-start + traverseAndConvertDates( + result.data ?? {}, + convertUTCToLocal, + splitDateTimeLocal, + ); + // coverage:ignore-end return result; } return noData; @@ -117,6 +125,11 @@ class DataBaseMutationFunctions { String mutation, { Map? variables, }) async { + // coverage:ignore-start + if (variables != null) { + traverseAndConvertDates(variables, convertLocalToUTC, splitDateTimeUTC); + } + // coverage:ignore-end final MutationOptions options = MutationOptions( document: gql(mutation), variables: variables ?? {}, @@ -157,6 +170,11 @@ class DataBaseMutationFunctions { Map? variables, bool reCall = true, }) async { + // coverage:ignore-start + if (variables != null) { + traverseAndConvertDates(variables, convertLocalToUTC, splitDateTimeUTC); + } + // coverage:ignore-end final MutationOptions options = MutationOptions( document: gql(mutation), variables: variables ?? {}, @@ -209,6 +227,13 @@ class DataBaseMutationFunctions { result.exception!, ); } else if (result.data != null && result.isConcrete) { + // coverage:ignore-start + traverseAndConvertDates( + result.data ?? {}, + convertUTCToLocal, + splitDateTimeLocal, + ); + // coverage:ignore-end return result; } return noData; diff --git a/lib/services/event_service.dart b/lib/services/event_service.dart index 32b513b02..25de549a0 100644 --- a/lib/services/event_service.dart +++ b/lib/services/event_service.dart @@ -59,17 +59,16 @@ class EventService extends BaseFeedManager { final String currentOrgID = _currentOrg.id!; // mutation to fetch the events final String mutation = EventQueries().fetchOrgEvents(currentOrgID); - final result = await _dbFunctions.gqlAuthMutation(mutation); + final result = await _dbFunctions.gqlAuthQuery(mutation); if (result.data == null) { throw Exception('unable to fetch data'); } - print(result.data!["eventsByOrganizationConnection"]); - final List> eventsJson = result - .data!["eventsByOrganizationConnection"] as List>; + final eventsJson = + result.data!["eventsByOrganizationConnection"] as List; eventsJson.forEach((eventJsonData) { - final Event event = Event.fromJson(eventJsonData); + final Event event = Event.fromJson(eventJsonData as Map); event.isRegistered = event.attendees?.any( (attendee) => attendee.id == _userConfig.currentUser.id, ) ?? @@ -325,6 +324,86 @@ class EventService extends BaseFeedManager { } } + /// This function is used to create an agenda item. + /// + /// **params**: + /// * `orgId`: ID of organisation to fetch categories. + /// + /// **returns**: + /// * `Future`: Information about the created agenda item. + Future fetchAgendaCategories(String orgId) async { + final result = await _dbFunctions.gqlAuthMutation( + EventQueries().fetchAgendaItemCategoriesByOrganization(orgId), + ); + return result; + } + + /// This function is used to create an agenda item. + /// + /// **params**: + /// * `variables`: A map of key-value pairs representing the variables required for the GraphQL mutation. + /// + /// **returns**: + /// * `Future`: Information about the created agenda item. + Future createAgendaItem(Map variables) async { + final result = await _dbFunctions.gqlAuthMutation( + EventQueries().createAgendaItem(), + variables: {'input': variables}, + ); + return result; + } + + /// This function is used to delete an agenda item. + /// + /// **params**: + /// * `variables`: A map of key-value pairs representing the variables required for the GraphQL mutation. + /// + /// **returns**: + /// * `Future`: Information about the deleted agenda item. + Future deleteAgendaItem(Map variables) async { + final result = await _dbFunctions.gqlAuthMutation( + EventQueries().deleteAgendaItem(), + variables: variables, + ); + return result; + } + + /// This function is used to update an agenda item. + /// + /// **params**: + /// * `itemId`: Id of agenda item which is to be updated + /// * `variables`: A map of key-value pairs representing the variables required for the GraphQL mutation. + /// + /// **returns**: + /// * `Future`: Information about the updated agenda item. + Future updateAgendaItem( + String itemId, + Map variables, + ) async { + final result = await _dbFunctions.gqlAuthMutation( + EventQueries().updateAgendaItem(), + variables: { + 'updateAgendaItemId': itemId, + 'input': variables, + }, + ); + return result; + } + + /// This function is used to fetch all agenda items for a specific organization. + /// + /// **params**: + /// * `eventId`: ID of the event to fetch agenda items. + /// + /// **returns**: + /// * `Future`: A list of agenda items for the specified organization. + Future fetchAgendaItems(String eventId) async { + final result = await _dbFunctions.gqlAuthQuery( + EventQueries().fetchAgendaItemsByEvent(eventId), + ); + return result; + } + /// This function is used to cancel the stream subscription of an organization. /// /// **params**: diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart index 8fe6cb950..b8a2a1a90 100644 --- a/lib/services/image_service.dart +++ b/lib/services/image_service.dart @@ -72,7 +72,6 @@ class ImageService { try { final List bytes = await file.readAsBytes(); final String base64String = base64Encode(bytes); - print(base64String); return base64String; } catch (error) { return ''; diff --git a/lib/utils/event_queries.dart b/lib/utils/event_queries.dart index b4abcd8ed..749a664ab 100644 --- a/lib/utils/event_queries.dart +++ b/lib/utils/event_queries.dart @@ -339,4 +339,147 @@ class EventQueries { } '''; } + + /// Creates a GraphQL query for fetching agenda item categories by organization. + /// + /// **params**: + /// * `organizationId`: The ID of the organization to fetch agenda item categories for. + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch agenda item categories. + String fetchAgendaItemCategoriesByOrganization(String organizationId) { + return """ + query { + agendaItemCategoriesByOrganization(organizationId: "$organizationId") { + _id + name + description + + } + } + """; + } + + /// Creates a GraphQL mutation for creating an agenda item. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to create an agenda item. + String createAgendaItem() { + return """ + mutation CreateAgendaItem(\$input: CreateAgendaItemInput!) { + createAgendaItem(input: \$input) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + } + } + """; + } + + /// Creates a GraphQL mutation for updating an agenda item. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to update an agenda item. + String updateAgendaItem() { + return """ + mutation UpdateAgendaItem(\$updateAgendaItemId: ID! + \$input: UpdateAgendaItemInput! + ) { + updateAgendaItem(id: \$updateAgendaItemId, input: \$input) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + } + } + """; + } + + /// Creates a GraphQL mutation for deleting an agenda item. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to delete an agenda item. + String deleteAgendaItem() { + return """ + mutation RemoveAgendaItem(\$removeAgendaItemId: ID!) { + removeAgendaItem(id: \$removeAgendaItemId) { + _id + } + } + """; + } + + /// Creates a GraphQL query for fetching agenda items by organization. + /// + /// **params**: + /// * `relatedEventId`: The ID of the event to fetch agenda items for. + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch agenda items. + String fetchAgendaItemsByEvent(String relatedEventId) { + return """ + query { + agendaItemByEvent(relatedEventId: "$relatedEventId") { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } + """; + } } diff --git a/lib/utils/time_conversion.dart b/lib/utils/time_conversion.dart new file mode 100644 index 000000000..103998d89 --- /dev/null +++ b/lib/utils/time_conversion.dart @@ -0,0 +1,162 @@ +import 'package:intl/intl.dart'; + +/// Combines the given date and time strings into a single string. +/// +/// **params**: +/// * `date`: The date string in a valid date format (e.g., 'YYYY-MM-DD'). +/// * `time`: The time string in a valid time format (e.g., 'HH:MM:SS'). +/// +/// **returns**: +/// * `String`: A string that combines the `date` and `time`, separated by a space. +String combineDateTime(String date, String time) { + return '$date $time'; +} + +/// Splits the given UTC date and time string into separate date and time strings. +/// +/// **params**: +/// * `dateTimeStr`: The UTC date and time string in a valid format. +/// +/// **returns**: +/// * `Map`: A map containing the separate date and time strings. +/// * Returns an empty map if the input is invalid. +Map splitDateTimeUTC(String dateTimeStr) { + try { + final DateTime dateTime = DateTime.parse(dateTimeStr); + return { + 'date': DateFormat('yyyy-MM-dd').format(dateTime), + 'time': DateFormat("HH:mm:ss.SSS'Z'").format(dateTime), + }; + } catch (e) { + print('Timezone Error parsing UTC date time: $e $dateTimeStr'); + return {}; + } +} + +/// Splits the given local date and time string into separate date and time strings. +/// +/// **params**: +/// * `dateTimeStr`: The local date and time string in a valid format. +/// +/// **returns**: +/// * `Map`: A map containing the separate date and time strings. +/// * Returns an empty map if the input is invalid. +Map splitDateTimeLocal(String dateTimeStr) { + try { + final DateTime dateTime = DateTime.parse(dateTimeStr); + return { + 'date': DateFormat('yyyy-MM-dd').format(dateTime), + 'time': DateFormat('HH:mm').format(dateTime), + }; + } catch (e) { + print('Timezone Error parsing local date time: $e $dateTimeStr'); + return {}; + } +} + +/// Converts the given UTC time to local time. +/// +/// **params**: +/// * `utcTime`: The UTC time string in a valid format. +/// +/// **returns**: +/// * `String`: The converted local time string. +/// * Returns an empty string if the input is invalid. +String convertUTCToLocal(String utcTime) { + try { + final DateTime dateTime = DateTime.parse(utcTime).toLocal(); + return DateFormat('yyyy-MM-ddTHH:mm:ss.SSS').format(dateTime); + } catch (e) { + print('Timezone Error converting UTC to local: $e $utcTime'); + return ''; + } +} + +/// Converts the given local time to UTC time. +/// +/// **params**: +/// * `localTime`: The local time string in a valid format. +/// +/// **returns**: +/// * `String`: The converted UTC time string. +/// * Returns an empty string if the input is invalid. +String convertLocalToUTC(String localTime) { + try { + final DateTime dateTime = DateTime.parse(localTime).toUtc(); + return DateFormat("yyyy-MM-ddTHH:mm:ss.SSS'Z'").format(dateTime); + } catch (e) { + print('Timezone Error converting local to UTC: $e $localTime'); + return ''; + } +} + +/// Traverses a nested map and converts date and time fields to the desired format. +/// +/// **params**: +/// * `obj`: The nested map to traverse and convert. +/// * `convertFn`: A function that converts a combined date and time string to the desired format. +/// * `splitFn`: A function that splits a converted date and time string into separate date and time strings. +/// +/// **returns**: +/// None +void traverseAndConvertDates( + Map obj, + String Function(String) convertFn, + Map Function(String) splitFn, +) { + obj.forEach((key, value) { + final pairedFields = + dateTimeFields['pairedFields']?.cast>(); + if (pairedFields != null) { + for (final field in pairedFields) { + if (key == field['dateField'] && obj.containsKey(field['timeField'])) { + final combinedDateTime = combineDateTime( + obj[field['dateField']] as String, + obj[field['timeField']] as String, + ); + + final convertedDateTime = convertFn(combinedDateTime); + + final splitDateTime = splitFn(convertedDateTime); + + obj[field['dateField'] ?? ''] = splitDateTime['date'] ?? ''; + obj[field['timeField'] ?? ''] = splitDateTime['time'] ?? ''; + } + } + } + + if (dateTimeFields['directFields']?.cast().contains(key) ?? false) { + obj[key] = convertFn(value as String); + } + + if (value is Map) { + traverseAndConvertDates(value, convertFn, splitFn); + } else if (value is List) { + for (final item in value) { + if (item is Map) { + traverseAndConvertDates(item, convertFn, splitFn); + } + } + } + }); +} + +/// Contains information about the date and time fields used for conversion. +const dateTimeFields = { + 'directFields': [ + 'createdAt', + 'birthDate', + 'updatedAt', + 'recurrenceStartDate', + 'recurrenceEndDate', + 'pluginCreatedBy', + 'dueDate', + 'completionDate', + 'startCursor', + 'endCursor', + ], + 'pairedFields': [ + {'dateField': 'startDate', 'timeField': 'startTime'}, + {'dateField': 'endDate', 'timeField': 'endTime'}, + ], +}; diff --git a/lib/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart b/lib/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart index 88bb6ad80..6d7f44371 100644 --- a/lib/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart +++ b/lib/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart @@ -225,12 +225,10 @@ class CreateEventViewModel extends BaseModel { 'organizationId': _currentOrg.id, 'startDate': DateFormat('yyyy-MM-dd').format(eventStartDate), 'endDate': DateFormat('yyyy-MM-dd').format(eventEndDate), - 'startTime': isAllDay - ? null - : '${DateFormat('HH:mm:ss').format(startTime)}Z', - 'endTime': isAllDay - ? null - : '${DateFormat('HH:mm:ss').format(endTime)}Z', + 'startTime': + isAllDay ? null : DateFormat('HH:mm:ss').format(startTime), + 'endTime': + isAllDay ? null : DateFormat('HH:mm:ss').format(endTime), }, if (isRecurring) 'recurrenceRuleData': { diff --git a/lib/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart b/lib/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart new file mode 100644 index 000000000..3445fb506 --- /dev/null +++ b/lib/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart @@ -0,0 +1,230 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; +import 'package:talawa/services/event_service.dart'; +import 'package:talawa/services/third_party_service/multi_media_pick_service.dart'; +import 'package:talawa/view_model/base_view_model.dart'; + +/// a_line_ending_with_end_punctuation. +class EditAgendaItemViewModel extends BaseModel { + final _eventService = locator(); + final _multiMediaPickerService = locator(); + + late EventAgendaItem _agendaItem; + List _categories = []; + List _selectedCategories = []; + List _initialUrls = []; + List _currentUrls = []; + List _initialAttachments = []; + List _currentAttachments = []; + + /// Controller for the title input field. + TextEditingController titleController = TextEditingController(); + + /// Controller for the description input field. + TextEditingController descriptionController = TextEditingController(); + + /// Controller for the URL input field. + TextEditingController urlController = TextEditingController(); + + /// Controller for the duration input field. + TextEditingController durationController = TextEditingController(); + + /// Get the list of all available categories. + List get categories => _categories; + + /// Get the list of selected categories. + List get selectedCategories => _selectedCategories; + + /// Get the list of URLs for the agenda item. + List get urls => _currentUrls; + + /// Get the list of attachments for the agenda item. + List get attachments => _currentAttachments; + + /// aInitializes the ViewModel with the provided agenda item and categories. + /// + /// **params**: + /// * `agendaItem`: The [EventAgendaItem] to be edited. + /// * `categories`: List of all available [AgendaCategory] for the event. + /// + /// **returns**: + /// None + void initialize(EventAgendaItem agendaItem, List categories) { + _agendaItem = agendaItem; + _categories = categories; + _fillEditForm(); + } + + /// Populates the form with the current agenda item details. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void _fillEditForm() { + titleController.text = _agendaItem.title ?? ''; + descriptionController.text = _agendaItem.description ?? ''; + durationController.text = _agendaItem.duration ?? ''; + _initialUrls = List.from(_agendaItem.urls ?? []); + _currentUrls = List.from(_initialUrls); + _initialAttachments = List.from(_agendaItem.attachments ?? []); + _currentAttachments = List.from(_initialAttachments); + final agendaCategoryIds = + _agendaItem.categories?.map((cat) => cat.id).toList() ?? []; + + _selectedCategories = _categories + .where((category) => agendaCategoryIds.contains(category.id)) + .toList(); + notifyListeners(); + } + + /// Updates the selected categories in the form. + /// + /// **params**: + /// * `categories`: The list of selected [AgendaCategory]. + /// + /// **returns**: + /// None + void setSelectedCategories(List categories) { + _selectedCategories = categories; + notifyListeners(); + } + + /// Adds a URL to the agenda item. + /// + /// **params**: + /// * `url`: The URL string to be added. + /// + /// **returns**: + /// None + void addUrl(String url) { + if (url.isNotEmpty) { + _currentUrls.add(url); + notifyListeners(); + } + } + + /// Removes a URL from the agenda item. + /// + /// **params**: + /// * `url`: The URL string to be removed. + /// + /// **returns**: + /// None + void removeUrl(String url) { + _currentUrls.remove(url); + notifyListeners(); + } + + /// Removes an attachment from the agenda item. + /// + /// **params**: + /// * `image`: The base64 string representing the attachment to be removed. + /// + /// **returns**: + /// None + void removeAttachment(String image) { + _currentAttachments.remove(image); + notifyListeners(); + } + + /// Picks an attachment for the agenda item from the gallery or camera. + /// + /// **params**: + /// * `fromCamera`: If `true`, opens the camera to pick an image, otherwise opens the gallery. + /// + /// **returns**: + /// None + Future pickAttachment({bool fromCamera = false}) async { + final File? pickedFile = + await _multiMediaPickerService.getPhotoFromGallery(camera: fromCamera); + if (pickedFile != null) { + final base64PickedFile = await imageService.convertToBase64(pickedFile); + _currentAttachments.add(base64PickedFile); + notifyListeners(); + } + } + + /// Checks if there are any unsaved changes in the form. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `bool`: define_the_return + bool checkForChanges() { + final bool titleChanged = titleController.text != (_agendaItem.title ?? ''); + final bool descriptionChanged = + descriptionController.text != (_agendaItem.description ?? ''); + final bool durationChanged = + durationController.text != (_agendaItem.duration ?? ''); + + final selectedCategoryIds = + _selectedCategories.map((cat) => cat.id).toSet(); + final agendaCategoryIds = + _agendaItem.categories?.map((cat) => cat.id).toSet() ?? {}; + final bool categoriesChanged = + !setEquals(selectedCategoryIds, agendaCategoryIds); + + final bool urlsChanged = !listEquals(_initialUrls, _currentUrls); + final bool attachmentsChanged = + !listEquals(_initialAttachments, _currentAttachments); + + final bool hasChange = titleChanged || + descriptionChanged || + durationChanged || + categoriesChanged || + urlsChanged || + attachmentsChanged; + + return hasChange; + } + + /// Updates the agenda item with the modified values. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + Future updateAgendaItem() async { + try { + if (!checkForChanges()) return; + final List attachmentPaths = _currentAttachments; + final List categoryIds = + _selectedCategories.map((category) => category.id!).toList(); + + final updatedAgendaItem = { + 'title': titleController.text, + 'description': descriptionController.text, + 'duration': durationController.text, + 'attachments': attachmentPaths, + 'urls': _currentUrls, + 'categories': categoryIds, + }; + + await _eventService.updateAgendaItem( + _agendaItem.id!, + updatedAgendaItem, + ) as QueryResult; + } catch (e) { + print('Error updating agenda item: $e'); + } + } + + @override + void dispose() { + titleController.dispose(); + descriptionController.dispose(); + urlController.dispose(); + durationController.dispose(); + super.dispose(); + } +} diff --git a/lib/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart b/lib/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart index 7072fd132..8393c1624 100644 --- a/lib/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart +++ b/lib/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:talawa/enums/enums.dart'; import 'package:talawa/locator.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; import 'package:talawa/models/events/event_model.dart'; import 'package:talawa/models/events/event_volunteer_group.dart'; import 'package:talawa/services/event_service.dart'; @@ -29,6 +31,20 @@ class EventInfoViewModel extends BaseModel { /// List of volunteer groups of an event. List get volunteerGroups => _volunteerGroups; + late List _agendaItems = []; + + /// List of volunteer groups of an event. + List get agendaItems => _agendaItems; + + late List _categories = []; + late List _selectedCategories = []; + + /// List of Agenda categories in an organisation. + List get categories => _categories; + + /// List of selected Agenda categories for an agenda item. + List get selectedCategories => _selectedCategories; + /// This function initializes the EventInfoViewModel class with the required arguments. /// /// **params**: @@ -41,7 +57,12 @@ class EventInfoViewModel extends BaseModel { exploreEventsInstance = args["exploreEventViewModel"] as ExploreEventsViewModel; fabTitle = getFabTitle(); + await fetchCategories(); + await fetchAgendaItems(); + selectedCategories.clear(); + setState(ViewState.busy); attendees = event.attendees ?? []; + setState(ViewState.idle); } /// The function allows user to register for an event. @@ -162,4 +183,201 @@ class EventInfoViewModel extends BaseModel { setState(ViewState.idle); } } + + /// Method to fecth all agenda categories of an organisation. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + Future fetchCategories() async { + try { + final result = await locator() + .fetchAgendaCategories(userConfig.currentOrg.id!) as QueryResult; + + if (result.data == null) return; + + final List categoryJson = + result.data!['agendaItemCategoriesByOrganization'] as List; + _categories = categoryJson + .map((json) => AgendaCategory.fromJson(json as Map)) + .toList(); + + notifyListeners(); + } catch (e) { + print('Error fetching categories: $e'); + } + } + + /// method to select multiple categories. + /// + /// **params**: + /// * `categories`: define_the_param + /// + /// **returns**: + /// None + void setSelectedCategories(List categories) { + _selectedCategories = categories; + notifyListeners(); + } + + /// Method to fetch all agenda items of an organization. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + Future fetchAgendaItems() async { + try { + final result = await locator().fetchAgendaItems(event.id!) + as QueryResult; + + if (result.data == null) return; + final List agendaJson = result.data!['agendaItemByEvent'] as List; + _agendaItems = agendaJson + .map((json) => EventAgendaItem.fromJson(json as Map)) + .toList(); + _agendaItems.sort((a, b) => a.sequence!.compareTo(b.sequence!)); + notifyListeners(); + } catch (e) { + print('Error fetching agenda items: $e'); + } + } + + /// This function is used to create a new agenda item for an event. + /// + /// **params**: + /// * `title`: Title of the agenda item. + /// * `description`: Description of the agenda item (optional). + /// * `duration`: Duration of the agenda item. + /// * `attachments`: List of attachment URLs (optional). + /// * `urls`: List of related URLs (optional). + /// * `categories`: List of category IDs (optional). + /// * `sequence`: Sequence number of the agenda item. + /// * `itemType`: Type of the agenda item. + /// * `isNote`: Whether the agenda item is a note or not. + /// + /// **returns**: + /// * `Future`: Returns the new agenda item if creation is successful. + Future createAgendaItem({ + required String title, + String? description, + required String duration, + List? attachments, + List? urls, + List? categories, + int? sequence, + }) async { + try { + final variables = { + 'title': title, + 'description': description, + 'duration': duration, + 'attachments': attachments, + 'relatedEventId': event.id, + 'urls': urls, + 'categories': categories, + 'sequence': _agendaItems.length + 1, + 'organizationId': userConfig.currentOrg.id, + }; + final result = await locator().createAgendaItem(variables) + as QueryResult; + if (result.data == null || result.data!['createAgendaItem'] == null) { + throw Exception('Failed to create agenda item or no data returned'); + } + + final data = result.data!['createAgendaItem']; + + final newAgendaItem = + EventAgendaItem.fromJson(data as Map); + + _agendaItems.add(newAgendaItem); + selectedCategories.clear(); + notifyListeners(); + + return newAgendaItem; + } catch (e) { + print('Error creating agenda item: $e'); + } + return null; + } + + /// Method to delete an agenda item. + /// + /// more_info_if_required + /// + /// **params**: + /// * `id`: id of the gaenda item that is to be deleted + /// + /// **returns**: + /// None + Future deleteAgendaItem(String id) async { + try { + await locator() + .deleteAgendaItem({"removeAgendaItemId": id}); + _agendaItems.removeWhere((item) => item.id == id); + notifyListeners(); + } catch (e) { + print('Error deleting agenda item: $e'); + } + } + + /// Method to update the sequence of an agenda item. + /// + /// **params**: + /// * `itemId`: id of the agenda item whose sequence need to be updated + /// * `newSequence`: new sequence of the item + /// + /// **returns**: + /// None + Future updateAgendaItemSequence(String itemId, int newSequence) async { + try { + final result = await locator().updateAgendaItem( + itemId, + {'sequence': newSequence}, + ) as QueryResult; + + final updatedItem = EventAgendaItem.fromJson( + result.data!['updateAgendaItem'] as Map, + ); + final index = _agendaItems.indexWhere((item) => item.id == itemId); + if (index != -1) { + _agendaItems[index] = updatedItem; + notifyListeners(); + } + } catch (e) { + print('Error updating agenda item sequence: $e'); + } + } + + /// Method to redorder the sequence of agenda items. + /// + /// **params**: + /// * `oldIndex`: old index of the item + /// * `newIndex`: new index of the item + /// + /// **returns**: + /// None + Future reorderAgendaItems(int oldIndex, int newIndex) async { + int adjustedNewIndex = newIndex; + + if (oldIndex < adjustedNewIndex) { + adjustedNewIndex -= 1; + } + + final EventAgendaItem item = _agendaItems.removeAt(oldIndex); + _agendaItems.insert(adjustedNewIndex, item); + + // Update sequences for all items + for (int i = 0; i < _agendaItems.length; i++) { + final item = _agendaItems[i]; + if (item.sequence != i + 1) { + await updateAgendaItemSequence(item.id!, i + 1); + } + } + + notifyListeners(); + } } diff --git a/lib/views/after_auth_screens/events/create_agenda_item_page.dart b/lib/views/after_auth_screens/events/create_agenda_item_page.dart new file mode 100644 index 000000000..10cbc8883 --- /dev/null +++ b/lib/views/after_auth_screens/events/create_agenda_item_page.dart @@ -0,0 +1,472 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/services/navigation_service.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/services/third_party_service/multi_media_pick_service.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/utils/validators.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; + +/// A page for creating a new agenda item for events. +class CreateAgendaItemPage extends StatefulWidget { + /// Creates an instance of [CreateAgendaItemPage]. + /// + /// **Params**: + /// * `model`: An instance of [EventInfoViewModel] to manage agenda item data. + const CreateAgendaItemPage({super.key, required this.model}); + + /// The ViewModel associated with this page. + final EventInfoViewModel model; + + @override + _CreateAgendaItemPageState createState() => _CreateAgendaItemPageState(); +} + +/// State class for [CreateAgendaItemPage]. +class _CreateAgendaItemPageState extends State { + /// Controller for the agenda item title input field. + TextEditingController titleController = TextEditingController(); + + /// Controller for the agenda item description input field. + TextEditingController descController = TextEditingController(); + + /// Controller for the URL input field. + TextEditingController urlController = TextEditingController(); + + /// Controller for the duration input field. + TextEditingController durationController = TextEditingController(); + + /// Focus node for the title input field. + FocusNode titleFocus = FocusNode(); + + /// Focus node for the description input field. + FocusNode descFocus = FocusNode(); + + /// Focus node for the URL input field. + FocusNode urlFocus = FocusNode(); + + /// Focus node for the duration input field. + FocusNode durationFocus = FocusNode(); + + /// List of selected categories for the agenda item. + List selectedCategories = []; + + /// List of URLs associated with the agenda item. + List urls = []; + + /// List of base64 encoded attachments associated with the agenda item. + List attachments = []; + + /// Service for picking multimedia files. + late MultiMediaPickerService _multiMediaPickerService; + + @override + void initState() { + super.initState(); + // Initialize the multimedia picker service. + _multiMediaPickerService = locator(); + } + + /// Handles the selection and deselection of categories. + /// + /// **params**: + /// * `category`: The category selected or deselected. + /// + /// **returns**: + /// None + void _onCategorySelected(AgendaCategory category) { + setState(() { + if (selectedCategories.contains(category)) { + selectedCategories.remove(category); + } else { + selectedCategories.add(category); + } + widget.model.setSelectedCategories( + selectedCategories, + ); + }); + } + + /// Removes a category from the selected categories list. + /// + /// **params**: + /// * `category`: The category to be removed. + /// + /// **returns**: + /// None + void _removeCategory(AgendaCategory category) { + setState(() { + selectedCategories.remove(category); + widget.model.setSelectedCategories(selectedCategories); + }); + } + + /// method to add urls. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void _addUrl() { + if (urlController.text.isNotEmpty) { + setState(() { + urls.add(urlController.text); + urlController.clear(); + }); + } + } + + /// Adds a URL to the list of URLs. + /// + /// **params**: + /// * `url`: url that is to be removed. + /// + /// **returns**: + /// None + void _removeUrl(String url) { + setState(() { + urls.remove(url); + }); + } + + /// Picks an attachment using the multimedia picker service. + /// + /// **params**: + /// * `fromCamera`: Indicates if the photo should be picked from the camera. + /// + /// **returns**: + /// None + Future _pickAttachment({bool fromCamera = false}) async { + final File? pickedFile = + await _multiMediaPickerService.getPhotoFromGallery(camera: fromCamera); + if (pickedFile != null) { + final base64PickedFile = await imageService + .convertToBase64(pickedFile); // Convert the file to base64. + setState(() { + attachments + .add(base64PickedFile); // Add the base64 string to attachments. + }); + } + } + + /// Removes an attachment from the list of attachments. + /// + /// **params**: + /// * `image`: The base64 string of the attachment to be removed. + /// + /// **returns**: + /// None + void _removeAttachment(String image) { + setState(() { + attachments.remove(image); + }); + } + + @override + Widget build(BuildContext context) { + final navigationServiceLocal = locator(); + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).primaryColor, + elevation: 1, + centerTitle: true, + leading: GestureDetector( + onTap: () => navigationServiceLocal.pop(), + child: const Icon(Icons.close), + ), + title: Text( + AppLocalizations.of(context)!.strictTranslate('Add Agenda Item'), + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + actions: [ + TextButton( + key: const Key('addButton'), + onPressed: () { + final List categoryIds = + selectedCategories.map((category) => category.id!).toList(); + + widget.model.createAgendaItem( + title: titleController.text, + duration: durationController.text, + description: descController.text, + urls: urls, + categories: categoryIds, + attachments: attachments, + ); + Navigator.of(context).pop(); + }, + child: Text( + key: const Key('add_agenda'), + AppLocalizations.of(context)!.strictTranslate('Add'), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 16, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ], + ), + body: Scrollbar( + thickness: 2, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + key: const Key('create_agenda_item_category_dropdown'), + value: selectedCategories.isNotEmpty + ? selectedCategories.first + : null, + onChanged: (AgendaCategory? category) { + if (category != null) { + _onCategorySelected(category); + } + }, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Select Categories'), + border: const OutlineInputBorder(), + ), + items: widget.model.categories + .map>( + (AgendaCategory category) { + return DropdownMenuItem( + value: category, + child: Text(category.name!), + ); + }).toList(), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: selectedCategories.map((category) { + return Chip( + label: Text(category.name!), + onDeleted: () => _removeCategory(category), + deleteIconColor: Colors.redAccent, + ); + }).toList(), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('create_event_agenda_tf1'), + textInputAction: TextInputAction.next, + controller: titleController, + keyboardType: TextInputType.name, + maxLength: 20, + focusNode: titleFocus, + validator: (value) => + Validator.validateEventForm(value!, 'Title'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Add Agenda Item Title'), + isDense: true, + labelStyle: Theme.of(context).textTheme.titleMedium, + focusedBorder: InputBorder.none, + counterText: "", + enabledBorder: InputBorder.none, + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.title, + size: 25, + ), + ), + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('create_event_agenda_tf2'), + keyboardType: TextInputType.multiline, + controller: descController, + focusNode: descFocus, + validator: (value) => + Validator.validateEventForm(value!, 'Description'), + maxLines: 10, + minLines: 1, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)! + .strictTranslate('Describe the event'), + labelText: AppLocalizations.of(context)! + .strictTranslate('Add Description'), + labelStyle: Theme.of(context).textTheme.titleMedium, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.view_headline, + size: 25, + ), + ), + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('create_event_agenda_duration'), + controller: durationController, + focusNode: durationFocus, + keyboardType: TextInputType.text, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Duration (mm:ss)'), + hintText: '00:00', + labelStyle: Theme.of(context).textTheme.titleMedium, + border: const OutlineInputBorder(), + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.timer, + size: 25, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)! + .strictTranslate('Please enter a duration'); + } + // Add additional validation for mm:ss format if needed + return null; + }, + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + Row( + children: [ + Expanded( + child: TextFormField( + controller: urlController, + focusNode: urlFocus, + maxLines: 5, + minLines: 1, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Add URL'), + labelStyle: Theme.of(context).textTheme.titleMedium, + border: const OutlineInputBorder(), + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.add_link_outlined, + size: 25, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: _addUrl, + child: Text( + key: const Key('add_url'), + AppLocalizations.of(context)!.strictTranslate('Add'), + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: urls.map((url) { + return Chip( + label: Text(url), + onDeleted: () => _removeUrl(url), + deleteIconColor: Colors.redAccent, + ); + }).toList(), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + ElevatedButton.icon( + onPressed: () => _pickAttachment(fromCamera: false), + icon: const Icon(Icons.attach_file), + label: Text( + AppLocalizations.of(context)! + .strictTranslate('Add Attachments'), + ), + ), + const Divider(), + const SizedBox(height: 10), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: attachments.length, + itemBuilder: (context, index) { + final base64String = attachments[index]; + final imageData = base64Decode(base64String); + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + imageData, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () => _removeAttachment(base64String), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ); + }, + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/after_auth_screens/events/edit_agenda_item_page.dart b/lib/views/after_auth_screens/events/edit_agenda_item_page.dart new file mode 100644 index 000000000..f620c3c63 --- /dev/null +++ b/lib/views/after_auth_screens/events/edit_agenda_item_page.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; + +import 'package:delightful_toast/delight_toast.dart'; +import 'package:delightful_toast/toast/components/toast_card.dart'; +import 'package:flutter/material.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; +import 'package:talawa/services/navigation_service.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/utils/validators.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart'; +import 'package:talawa/views/base_view.dart'; + +/// Screen for edit agenda item. +class EditAgendaItemPage extends StatefulWidget { + const EditAgendaItemPage({ + super.key, + required this.agendaItem, + required this.categories, + }); + + /// agenda item that is going to be be updated. + final EventAgendaItem agendaItem; + + /// list of categories in that organisation. + final List categories; + + @override + _EditAgendaItemPageState createState() => _EditAgendaItemPageState(); +} + +class _EditAgendaItemPageState extends State { + @override + Widget build(BuildContext context) { + return BaseView( + onModelReady: (model) => + model.initialize(widget.agendaItem, widget.categories), + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).primaryColor, + elevation: 1, + centerTitle: true, + leading: GestureDetector( + onTap: () => locator().pop(), + child: const Icon(Icons.close), + ), + title: Text( + AppLocalizations.of(context)!.strictTranslate('Edit Agenda Item'), + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + actions: [ + TextButton( + key: const Key('updateButton'), + onPressed: () async { + if (model.checkForChanges()) { + await model.updateAgendaItem(); + if (context.mounted) { + Navigator.of(context).pop(true); + } + } else { + DelightToastBar( + snackbarDuration: const Duration(seconds: 2), + builder: (context) { + return ToastCard( + title: const Text( + "No changes made", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + leading: const Icon( + Icons.error_outline, + color: Colors.redAccent, + ), + color: Colors.black.withOpacity(0.8), + ); + }, + ).show(context); + } + }, + child: Text( + AppLocalizations.of(context)!.strictTranslate('Update'), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: 16, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ], + ), + body: Scrollbar( + thickness: 2, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + key: const Key('edit_agenda_item_category_dropdown'), + value: model.selectedCategories.isNotEmpty + ? model.selectedCategories.first + : null, + onChanged: (AgendaCategory? category) { + if (category != null) { + final updatedCategories = List.from( + model.selectedCategories, + ); + if (updatedCategories.contains(category)) { + updatedCategories.remove(category); + } else { + updatedCategories.add(category); + } + model.setSelectedCategories(updatedCategories); + } + }, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Select Categories'), + border: const OutlineInputBorder(), + ), + items: model.categories + .map>( + (AgendaCategory category) { + return DropdownMenuItem( + value: category, + child: Text(category.name!), + ); + }).toList(), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: model.selectedCategories.map((category) { + return Chip( + key: Key(category.name!), + label: Text(category.name!), + onDeleted: () { + final updatedCategories = List.from( + model.selectedCategories, + ); + updatedCategories.remove(category); + model.setSelectedCategories(updatedCategories); + }, + deleteIconColor: Colors.redAccent, + ); + }).toList(), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('edit_event_agenda_tf1'), + textInputAction: TextInputAction.next, + controller: model.titleController, + keyboardType: TextInputType.name, + maxLength: 20, + validator: (value) => + Validator.validateEventForm(value!, 'Title'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Agenda Item Title'), + isDense: true, + labelStyle: Theme.of(context).textTheme.titleMedium, + focusedBorder: InputBorder.none, + counterText: "", + enabledBorder: InputBorder.none, + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.title, + size: 25, + ), + ), + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('edit_event_agenda_tf2'), + keyboardType: TextInputType.multiline, + controller: model.descriptionController, + validator: (value) => + Validator.validateEventForm(value!, 'Description'), + maxLines: 10, + minLines: 1, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)! + .strictTranslate('Describe the agenda item'), + labelText: AppLocalizations.of(context)! + .strictTranslate('Description'), + labelStyle: Theme.of(context).textTheme.titleMedium, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.view_headline, + size: 25, + ), + ), + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + TextFormField( + key: const Key('edit_event_agenda_duration'), + controller: model.durationController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Duration (mm:ss)'), + hintText: '00:00', + labelStyle: Theme.of(context).textTheme.titleMedium, + border: const OutlineInputBorder(), + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.timer, + size: 25, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)! + .strictTranslate('Please enter a duration'); + } + // Add additional validation for mm:ss format if needed + return null; + }, + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + Row( + children: [ + Expanded( + child: TextFormField( + key: const Key('urlTextField'), + controller: model.urlController, + maxLines: 5, + minLines: 1, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .strictTranslate('Add URL'), + labelStyle: + Theme.of(context).textTheme.titleMedium, + border: const OutlineInputBorder(), + prefixIcon: Container( + transform: Matrix4.translationValues( + -SizeConfig.screenWidth! * 0.027, + 0.0, + 0.0, + ), + child: const Icon( + Icons.add_link_outlined, + size: 25, + ), + ), + ), + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + ElevatedButton( + onPressed: () { + model.addUrl(model.urlController.text); + model.urlController.clear(); + }, + child: Text( + AppLocalizations.of(context)! + .strictTranslate('Add'), + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: model.urls.map((url) { + return Chip( + key: Key(url), + label: Text(url), + onDeleted: () => model.removeUrl(url), + deleteIconColor: Colors.redAccent, + ); + }).toList(), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + ElevatedButton.icon( + onPressed: () => model.pickAttachment(fromCamera: false), + icon: const Icon(Icons.attach_file), + label: Text( + AppLocalizations.of(context)! + .strictTranslate('Add Attachments'), + ), + ), + const Divider(), + const SizedBox(height: 10), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: model.attachments.length, + itemBuilder: (context, index) { + final base64String = model.attachments[index]; + final imageData = base64Decode(base64String); + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + imageData, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () => + model.removeAttachment(base64String), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ); + }, + ), + SizedBox(height: SizeConfig.screenHeight! * 0.013), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/after_auth_screens/events/event_info_page.dart b/lib/views/after_auth_screens/events/event_info_page.dart index c76962d99..fd4c926a8 100644 --- a/lib/views/after_auth_screens/events/event_info_page.dart +++ b/lib/views/after_auth_screens/events/event_info_page.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:talawa/locator.dart'; +import 'package:talawa/models/events/event_model.dart'; import 'package:talawa/services/size_config.dart'; import 'package:talawa/utils/app_localization.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; import 'package:talawa/views/after_auth_screens/events/event_info_body.dart'; +import 'package:talawa/views/after_auth_screens/events/manage_agenda_items_screen.dart'; import 'package:talawa/views/after_auth_screens/events/volunteer_groups_screen.dart'; import 'package:talawa/views/base_view.dart'; @@ -27,7 +29,12 @@ class _EventInfoPageState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + // TabController length will depend on whether the user is the event creator + final bool isCreator = (widget.args["event"] as Event).creator!.id == + userConfig.currentUser.id; + final int tabCount = isCreator ? 3 : 2; + + _tabController = TabController(length: tabCount, vsync: this); _tabController.addListener(() { setState(() { _showFloatingActionButton = _tabController.index == 0; @@ -46,6 +53,9 @@ class _EventInfoPageState extends State return BaseView( onModelReady: (model) => model.initialize(args: widget.args), builder: (context, model, child) { + final bool isCreator = model.event.creator != null && + model.event.creator!.id == userConfig.currentUser.id; + return Scaffold( appBar: AppBar( title: Text( @@ -54,9 +64,20 @@ class _EventInfoPageState extends State bottom: TabBar( key: const Key("tabBar"), controller: _tabController, - tabs: const [ - Tab(text: "Info"), - Tab(text: "Volunteers"), + tabs: [ + const Tab( + text: "Info", + key: Key('info_tag'), + ), + const Tab( + text: "Volunteers", + key: Key('volunteer_tag'), + ), + if (isCreator) + const Tab( + text: "Agendas", + key: Key('agenda_tag'), + ), ], ), ), @@ -109,6 +130,7 @@ class _EventInfoPageState extends State : null, ), VolunteerGroupsScreen(event: model.event, model: model), + if (isCreator) const ManageAgendaScreen(), ], ), ); diff --git a/lib/views/after_auth_screens/events/manage_agenda_items_screen.dart b/lib/views/after_auth_screens/events/manage_agenda_items_screen.dart new file mode 100644 index 000000000..0c27135b0 --- /dev/null +++ b/lib/views/after_auth_screens/events/manage_agenda_items_screen.dart @@ -0,0 +1,108 @@ +import 'package:delightful_toast/delight_toast.dart'; +import 'package:delightful_toast/toast/components/toast_card.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; +import 'package:talawa/views/after_auth_screens/events/create_agenda_item_page.dart'; +import 'package:talawa/views/after_auth_screens/events/edit_agenda_item_page.dart'; +import 'package:talawa/widgets/agenda_item_tile.dart'; + +/// Agenda section screen to manage agendas. +class ManageAgendaScreen extends StatelessWidget { + const ManageAgendaScreen({super.key}); + + @override + Widget build(BuildContext context) { + final model = Provider.of(context); + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: model.agendaItems.isEmpty + ? Center( + child: Text( + 'No agenda items yet', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + color: Colors.white, + ), + ), + ) + : ReorderableListView.builder( + itemCount: model.agendaItems.length, + onReorder: (oldIndex, newIndex) { + model.reorderAgendaItems(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final item = model.agendaItems[index]; + return ExpandableAgendaItemTile( + key: Key("agenda_item$index"), + item: item, + onEdit: () async { + final bool? wasUpdated = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EditAgendaItemPage( + agendaItem: item, + categories: model.categories, + ), + ), + ); + // Refresh agenda items only if changes were made + if (wasUpdated == true) { + await model.fetchAgendaItems(); + } + }, + onDelete: () async { + await model.deleteAgendaItem(item.id!); + if (context.mounted) { + DelightToastBar( + autoDismiss: true, + snackbarDuration: const Duration(seconds: 2), + builder: (context) { + return ToastCard( + title: const Text( + "Agenda item removed", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + leading: const Icon( + Icons.error_outline, + color: Colors.redAccent, + ), + color: Colors.black.withOpacity(0.8), + ); + }, + ).show(context); + } + }, + ); + }, + ), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.011), + ElevatedButton.icon( + key: const Key('add_item_btn'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CreateAgendaItemPage(model: model), + ), + ); + }, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text('Add Agenda Item'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/agenda_item_tile.dart b/lib/widgets/agenda_item_tile.dart new file mode 100644 index 000000000..991d6ac3c --- /dev/null +++ b/lib/widgets/agenda_item_tile.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; + +/// A widget that displays an expandable agenda item tile. +class ExpandableAgendaItemTile extends StatelessWidget { + const ExpandableAgendaItemTile({ + super.key, + required this.item, + required this.onEdit, + required this.onDelete, + }); + + /// Agenda Item whose data to be displayed. + final EventAgendaItem item; + + /// edit callback for the agenda item. + final VoidCallback onEdit; + + /// on delete callback for agenda item. + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Card( + color: const Color.fromARGB(255, 70, 69, 69), + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: ExpansionTile( + leading: ReorderableDragStartListener( + key: const Key('reorder_icon'), + index: item.sequence! - 1, + child: const Icon(Icons.drag_handle), + ), + title: Text( + item.title!, + style: const TextStyle( + color: Colors.green, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: Key("edit_agenda_item${item.id}"), + icon: const Icon(Icons.edit, color: Colors.blue, size: 20), + onPressed: onEdit, + ), + IconButton( + key: Key("delete_agenda_item${item.id}"), + icon: const Icon(Icons.delete, color: Colors.red, size: 20), + onPressed: onDelete, + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Categories:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: (item.categories ?? []).map((category) { + return Chip( + label: Text( + category.name!, + style: const TextStyle(fontSize: 12), + ), + padding: const EdgeInsets.all(4), + ); + }).toList(), + ), + const SizedBox(height: 8), + const Text( + 'Description:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + Text(item.description ?? ''), + const SizedBox(height: 8), + const Text( + 'Duration:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + Text(item.duration ?? ''), + const SizedBox(height: 8), + if ((item.urls ?? []).isNotEmpty) ...[ + const Text( + 'URLs:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: (item.urls ?? []).map((url) { + return Text( + url, + style: const TextStyle(fontSize: 14), + ); + }).toList(), + ), + const SizedBox(height: 8), + ], + if ((item.attachments ?? []).isNotEmpty) ...[ + const Text( + 'Attachments:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: item.attachments!.length, + itemBuilder: (context, index) { + final base64String = item.attachments![index]; + try { + final imageData = base64Decode(base64String); + return GestureDetector( + onTap: () => _showFullScreenImage(context, imageData), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + imageData, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ); + } catch (e) { + return Container( + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.attachment, + color: Colors.white, + ), + ); + } + }, + ), + const SizedBox(height: 8), + ], + ], + ), + ), + ], + ), + ); + } + + /// show Image in full screen mode. + /// + /// **params**: + /// * `context`: context for the image + /// * `imageData`: data of the Image that is to be viewed + /// + /// **returns**: + /// None + void _showFullScreenImage(BuildContext context, Uint8List imageData) { + showDialog( + context: context, + builder: (BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: ColoredBox( + color: Colors.black.withOpacity(0.5), + child: Center( + child: InteractiveViewer( + panEnabled: true, + boundaryMargin: const EdgeInsets.all(20), + minScale: 0.5, + maxScale: 4, + child: Image.memory( + imageData, + fit: BoxFit.contain, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8bf510a0a..bec495176 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "0.4.1" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf @@ -365,10 +365,10 @@ packages: dependency: "direct main" description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" file_selector_linux: dependency: transitive description: @@ -450,26 +450,26 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "49eeef364fddb71515bc78d5a8c51435a68bccd6e4d68e25a942c5e47761ae71" + sha256: "725145682706fb0e5a30f93e5cb64f3df7ed7743de749bd555b22bf75ee718c0" url: "https://pub.dev" source: hosted - version: "17.2.3" + version: "18.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "5.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "8.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -511,10 +511,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + sha256: "578bd8c508144fdaffd4f77b8ef2d8c523602275cd697cc3db284dbd762ef4ce" url: "https://pub.dev" source: hosted - version: "2.0.10+1" + version: "2.0.14" flutter_test: dependency: "direct dev" description: flutter @@ -529,10 +529,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: "275ff26905134bcb59417cf60ad979136f1f8257f2f449914b2c3e05bbb4cd6f" + sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a url: "https://pub.dev" source: hosted - version: "10.7.0" + version: "10.8.0" freezed_annotation: dependency: transitive description: @@ -553,10 +553,10 @@ packages: dependency: "direct main" description: name: get_it - sha256: ff97e5e7b2e82e63c82f5658c6ba2605ea831f0f7489b0d2fb255d817ec4eb5e + sha256: c49895c1ecb0ee2a0ec568d39de882e2c299ba26355aa6744ab1001f98cebd15 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.0.2" glob: dependency: transitive description: @@ -993,18 +993,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.10" path_provider_foundation: dependency: transitive description: @@ -1177,10 +1177,10 @@ packages: dependency: "direct main" description: name: quick_actions - sha256: b17da113df7a7005977f64adfa58ccc49c829d3ccc6e8e770079a8c7fbf2da9e + sha256: "2c1d9a91f3218b4e987a7e1e95ba0415b7f48a2cb3ffacc027a1e3d3c117223f" url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.8" quick_actions_android: dependency: transitive description: @@ -1217,10 +1217,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" shared_preferences_android: dependency: transitive description: @@ -1390,26 +1390,26 @@ packages: dependency: "direct main" description: name: syncfusion_flutter_calendar - sha256: c501e463e69ed6f69323ead6b3b47873e5f25d00ec481fe2429f9ebbac92271b + sha256: "769d3bbf8743922d74b242a968366661bd7b2973b3d34af9b9bc865874a520d9" url: "https://pub.dev" source: hosted - version: "27.1.48" + version: "27.1.58" syncfusion_flutter_core: dependency: transitive description: name: syncfusion_flutter_core - sha256: "4347f4d2f5d89461df2c53e6fbf53aef38c7f05ed79b0760d935fb1ec836213b" + sha256: "31d2ddf410ee41abb3ecf85b7b6e8e1563307ad52ee784ddd91337e30280f715" url: "https://pub.dev" source: hosted - version: "27.1.48" + version: "27.1.58" syncfusion_flutter_datepicker: dependency: "direct main" description: name: syncfusion_flutter_datepicker - sha256: "239331a866e57794925d29f9a68af0f52b2d1942001588e11f49729de2c670b0" + sha256: e25797401bec43cd64c475150f87150e8bc3e67212d4d1273ff35483ea793a8b url: "https://pub.dev" source: hosted - version: "27.1.48" + version: "27.1.58" synchronized: dependency: transitive description: @@ -1469,10 +1469,10 @@ packages: dependency: "direct main" description: name: tutorial_coach_mark - sha256: "1f1fd234790afb929dec7391a4d90aa54ffe8c8e4d278d9283df8e3f5ac5d63e" + sha256: df450c88d4c812bc221afd3ff948da3dc0f44c0b4fa5dbc046d6d86f2cfc9e71 url: "https://pub.dev" source: hosted - version: "1.2.11" + version: "1.2.12" typed_data: dependency: transitive description: @@ -1509,10 +1509,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: @@ -1581,10 +1581,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + sha256: "773c9522d66d523e1c7b25dfb95cc91c26a1e17b107039cfe147285e92de7878" url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.14" vector_graphics_codec: dependency: transitive description: @@ -1597,10 +1597,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + sha256: ab9ff38fc771e9ee1139320adbe3d18a60327370c218c60752068ebee4b49ab1 url: "https://pub.dev" source: hosted - version: "1.1.11+1" + version: "1.1.15" vector_math: dependency: transitive description: @@ -1613,26 +1613,26 @@ packages: dependency: "direct main" description: name: vibration - sha256: fe8f90e1827f86a4f722b819799ecac8a24789a39c6d562ea316bcaeb8b1ec61 + sha256: f0af02af2d63132135ae0332a3e54d5de718e214ee94c4f082176ef6ce624a4b url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" vibration_platform_interface: dependency: transitive description: name: vibration_platform_interface - sha256: "735a5fef0f284de0ad9449a5ed7d36ba017c6f59b5b20ac64418af4a6bd35ee7" + sha256: f66b39aab2447038978c16f3d6f77228e49ef5717556e3da02313e044e4a7600 url: "https://pub.dev" source: hosted - version: "0.0.1" + version: "0.0.2" video_player: dependency: "direct main" description: name: video_player - sha256: e30df0d226c4ef82e2c150ebf6834b3522cf3f654d8e2f9419d376cdc071425d + sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.9.2" video_player_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61beb2463..c18850a81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: auto_size_text: ^3.0.0 cached_network_image: ^3.4.1 + clock: ^1.1.1 connectivity_plus: ^5.0.2 contained_tab_bar_view: ^0.8.0 @@ -32,20 +33,20 @@ dependencies: # custom_lint_builder: ^0.4.0 ################################ delightful_toast: ^1.1.0 - file: ^7.0.0 + file: ^7.0.1 flutter: sdk: flutter flutter_braintree: ^4.0.0 flutter_cache_manager: ^3.4.1 - flutter_local_notifications: ^17.2.3 + flutter_local_notifications: ^18.0.0 flutter_localizations: sdk: flutter flutter_reaction_button: ^3.0.0+3 flutter_speed_dial: ^7.0.0 - flutter_svg: ^2.0.10+1 - font_awesome_flutter: ^10.7.0 - get_it: ^8.0.0 + flutter_svg: ^2.0.14 + font_awesome_flutter: ^10.8.0 + get_it: ^8.0.2 graphql_flutter: ^5.1.2 hive: ^2.2.3 http: ^1.2.2 @@ -55,26 +56,26 @@ dependencies: json_annotation: ^4.7.0 mockito: ^5.4.4 network_image_mock: ^2.1.1 - path_provider: ^2.1.4 + path_provider: ^2.1.5 permission_handler: 11.3.1 plugin_platform_interface: ^2.1.7 pointycastle: ^3.9.1 provider: ^6.1.2 qr_code_scanner: ^1.0.0 qr_flutter: 4.1.0 - quick_actions: ^1.0.6 - shared_preferences: ^2.3.2 + quick_actions: ^1.0.8 + shared_preferences: ^2.3.3 shimmer: ^3.0.0 social_share: ^2.2.1 - syncfusion_flutter_calendar: ^27.1.48 - syncfusion_flutter_datepicker: ^27.1.48 + syncfusion_flutter_calendar: ^27.1.58 + syncfusion_flutter_datepicker: ^27.1.58 timelines: ^0.1.0 - tutorial_coach_mark: ^1.2.11 + tutorial_coach_mark: ^1.2.12 uni_links: ^0.5.1 uni_links_platform_interface: ^1.0.0 - url_launcher: ^6.3.0 - vibration: ^2.0.0 - video_player: ^2.9.1 + url_launcher: ^6.3.1 + vibration: ^2.0.1 + video_player: ^2.9.2 visibility_detector: ^0.4.0+2 dev_dependencies: diff --git a/test/helpers/test_helpers.dart b/test/helpers/test_helpers.dart index ed301a368..f8e453385 100644 --- a/test/helpers/test_helpers.dart +++ b/test/helpers/test_helpers.dart @@ -36,6 +36,7 @@ import 'package:talawa/view_model/after_auth_view_models/add_post_view_models/ad import 'package:talawa/view_model/after_auth_view_models/chat_view_models/direct_chat_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/chat_view_models/select_contact_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/feed_view_models/organization_feed_view_model.dart'; @@ -931,6 +932,7 @@ void registerViewModels() { locator.registerFactory(() => SizeConfig()); locator.registerFactory(() => DirectChatViewModel()); locator.registerFactory(() => WaitingViewModel()); + locator.registerFactory(() => EditAgendaItemViewModel()); locator.registerFactory(() => EventInfoViewModel()); locator.registerFactory(() => ProgressDialogViewModel()); locator.registerFactory(() => SelectOrganizationViewModel()); @@ -947,6 +949,7 @@ void registerViewModels() { /// None void unregisterViewModels() { locator.unregister(); + locator.unregister(); locator.unregister(); locator.unregister(); locator.unregister(); diff --git a/test/helpers/test_helpers.mocks.dart b/test/helpers/test_helpers.mocks.dart index a0c12cc72..417efbb51 100644 --- a/test/helpers/test_helpers.mocks.dart +++ b/test/helpers/test_helpers.mocks.dart @@ -1547,6 +1547,66 @@ class MockEventService extends _i2.Mock implements _i11.EventService { <_i22.EventVolunteerGroup>[]), ) as _i5.Future>); + @override + _i5.Future fetchAgendaCategories(String? orgId) => + (super.noSuchMethod( + Invocation.method( + #fetchAgendaCategories, + [orgId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future createAgendaItem(Map? variables) => + (super.noSuchMethod( + Invocation.method( + #createAgendaItem, + [variables], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future deleteAgendaItem(Map? variables) => + (super.noSuchMethod( + Invocation.method( + #deleteAgendaItem, + [variables], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future updateAgendaItem( + String? itemId, + Map? variables, + ) => + (super.noSuchMethod( + Invocation.method( + #updateAgendaItem, + [ + itemId, + variables, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future fetchAgendaItems(String? eventId) => (super.noSuchMethod( + Invocation.method( + #fetchAgendaItems, + [eventId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override void dispose() => super.noSuchMethod( Invocation.method( diff --git a/test/helpers/test_locator.dart b/test/helpers/test_locator.dart index 746a8608c..5d0b80a7f 100644 --- a/test/helpers/test_locator.dart +++ b/test/helpers/test_locator.dart @@ -28,6 +28,7 @@ import 'package:talawa/view_model/after_auth_view_models/add_post_view_models/ad import 'package:talawa/view_model/after_auth_view_models/chat_view_models/direct_chat_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/chat_view_models/select_contact_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_event_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; @@ -123,6 +124,7 @@ void testSetupLocator() { locator.registerFactory(() => SetUrlViewModel()); locator.registerFactory(() => LoginViewModel()); locator.registerFactory(() => ManageVolunteerGroupViewModel()); + locator.registerFactory(() => EditAgendaItemViewModel()); locator.registerFactory(() => SelectOrganizationViewModel()); locator.registerFactory(() => AccessScreenViewModel()); locator.registerFactory(() => SignupDetailsViewModel()); diff --git a/test/model_tests/events/event_agenda_category_test.dart b/test/model_tests/events/event_agenda_category_test.dart new file mode 100644 index 000000000..df5df9c15 --- /dev/null +++ b/test/model_tests/events/event_agenda_category_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; + +void main() { + group('Test AgendaCategory Model', () { + test('Test AgendaCategory fromJson', () { + final agendaCategory = AgendaCategory( + id: 'category1', + name: 'Discussion', + description: 'A general discussion session', + ); + + final agendaCategoryJson = { + '_id': 'category1', + 'name': 'Discussion', + 'description': 'A general discussion session', + }; + + final agendaCategoryFromJson = + AgendaCategory.fromJson(agendaCategoryJson); + + // Verifying that all fields were correctly deserialized + expect(agendaCategory.id, agendaCategoryFromJson.id); + expect(agendaCategory.name, agendaCategoryFromJson.name); + expect(agendaCategory.description, agendaCategoryFromJson.description); + }); + + test('Test AgendaCategory fromJson with null values', () { + final agendaCategoryJson = { + '_id': null, + 'name': null, + 'description': null, + }; + + final agendaCategoryFromJson = + AgendaCategory.fromJson(agendaCategoryJson); + + // Verifying that null values are handled correctly + expect(agendaCategoryFromJson.id, isNull); + expect(agendaCategoryFromJson.name, isNull); + expect(agendaCategoryFromJson.description, isNull); + }); + }); +} diff --git a/test/model_tests/events/event_agenda_item_test.dart b/test/model_tests/events/event_agenda_item_test.dart new file mode 100644 index 000000000..020ee0df6 --- /dev/null +++ b/test/model_tests/events/event_agenda_item_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; + +void main() { + group('Test EventAgendaItem Model', () { + test('Test EventAgendaItem fromJson', () { + final eventAgendaItemJson = { + '_id': 'item1', + 'title': 'Opening Remarks', + 'description': 'Welcome speech and event overview', + 'duration': '00:30', + 'attachments': ['https://example.com/attachment1.pdf'], + 'createdBy': { + '_id': 'user1', + 'firstName': 'John', + 'lastName': 'Doe', + }, + 'urls': ['https://example.com/agenda'], + 'relatedEvent': { + '_id': 'event1', + 'title': 'Annual Conference', + }, + 'categories': [ + { + '_id': 'category1', + 'name': 'Introduction', + } + ], + 'sequence': 1, + 'organization': { + '_id': 'org1', + 'name': 'Tech Conference Org', + }, + }; + + final eventAgendaItem = EventAgendaItem.fromJson(eventAgendaItemJson); + + // Verifying that all fields were correctly deserialized + expect(eventAgendaItem.id, 'item1'); + expect(eventAgendaItem.title, 'Opening Remarks'); + expect(eventAgendaItem.description, 'Welcome speech and event overview'); + expect(eventAgendaItem.duration, '00:30'); + expect( + eventAgendaItem.attachments, + ['https://example.com/attachment1.pdf'], + ); + expect(eventAgendaItem.createdBy?.id, 'user1'); + expect(eventAgendaItem.urls, ['https://example.com/agenda']); + expect(eventAgendaItem.relatedEvent?.id, 'event1'); + expect(eventAgendaItem.categories?.length, 1); + expect(eventAgendaItem.categories?[0].id, 'category1'); + expect(eventAgendaItem.sequence, 1); + expect(eventAgendaItem.organization?.id, 'org1'); + }); + + test('Test EventAgendaItem fromJson with null values', () { + final eventAgendaItemJson = { + '_id': null, + 'title': null, + 'description': null, + 'duration': null, + 'attachments': null, + 'createdBy': null, + 'urls': null, + 'relatedEvent': null, + 'categories': null, + 'sequence': null, + 'organization': null, + }; + + final eventAgendaItem = EventAgendaItem.fromJson(eventAgendaItemJson); + + // Verifying that null values are handled correctly + expect(eventAgendaItem.id, isNull); + expect(eventAgendaItem.title, isNull); + expect(eventAgendaItem.description, isNull); + expect(eventAgendaItem.duration, isNull); + expect(eventAgendaItem.attachments, isNull); + expect(eventAgendaItem.createdBy, isNull); + expect(eventAgendaItem.urls, isNull); + expect(eventAgendaItem.relatedEvent, isNull); + expect(eventAgendaItem.categories, isNull); + expect(eventAgendaItem.sequence, isNull); + expect(eventAgendaItem.organization, isNull); + }); + }); +} diff --git a/test/service_tests/event_service_test.dart b/test/service_tests/event_service_test.dart index 9697175e1..053370fb7 100644 --- a/test/service_tests/event_service_test.dart +++ b/test/service_tests/event_service_test.dart @@ -23,6 +23,7 @@ void main() { testSetupLocator(); registerServices(); }); + group('Test EventService', () { test('Test editEvent method', () async { final dataBaseMutationFunctions = locator(); @@ -118,7 +119,7 @@ void main() { (realInvocation) async => QueryResult( options: QueryOptions(document: gql(query)), data: { - 'cretedEvent': { + 'createdEvent': { '_id': 'eventId', 'title': 'Test task', 'description': 'Test description', @@ -137,12 +138,13 @@ void main() { when( dataBaseMutationFunctions.gqlAuthMutation( EventQueries().registerForEvent(), + variables: {'eventId': 'eventId'}, ), ).thenAnswer( (realInvocation) async => QueryResult( options: QueryOptions(document: gql(query)), data: { - 'register for an event': { + 'registerForEvent': { '_id': 'eventId', }, }, @@ -250,6 +252,7 @@ void main() { final model = EventService(); expect(model.eventStream, isA>>()); }); + test('Test createVolunteerGroup method', () async { final dataBaseMutationFunctions = locator(); const query = ''; @@ -451,5 +454,160 @@ void main() { expect(result[0].id, 'groupId1'); expect(result[1].id, 'groupId2'); }); + test('fetchAgendaCategories returns correct data', () async { + const orgId = 'org123'; + final mockResult = QueryResult( + options: QueryOptions( + document: gql( + EventQueries().fetchAgendaItemCategoriesByOrganization(orgId), + ), + ), + data: { + 'fetchAgendaCategories': [ + {'_id': 'cat1', 'name': 'Category 1'}, + {'_id': 'cat2', 'name': 'Category 2'}, + ], + }, + source: QueryResultSource.network, + ); + + when( + databaseFunctions.gqlAuthMutation( + EventQueries().fetchAgendaItemCategoriesByOrganization(orgId), + ), + ).thenAnswer((_) async => mockResult); + + final service = EventService(); + final result = await service.fetchAgendaCategories(orgId); + + expect(result, equals(mockResult)); + verify( + databaseFunctions.gqlAuthMutation( + EventQueries().fetchAgendaItemCategoriesByOrganization(orgId), + ), + ).called(1); + }); + + test('createAgendaItem sends correct mutation and variables', () async { + final variables = {'title': 'New Agenda', 'description': 'Description'}; + final mockResult = QueryResult( + options: QueryOptions(document: gql('')), + data: { + 'createAgendaItem': {'_id': 'agenda1'}, + }, + source: QueryResultSource.network, + ); + + when( + databaseFunctions.gqlAuthMutation( + EventQueries().createAgendaItem(), + variables: {'input': variables}, + ), + ).thenAnswer((_) async => mockResult); + + final service = EventService(); + + final result = await service.createAgendaItem(variables); + + expect(result, equals(mockResult)); + verify( + databaseFunctions.gqlAuthMutation( + EventQueries().createAgendaItem(), + variables: {'input': variables}, + ), + ).called(1); + }); + + test('deleteAgendaItem sends correct mutation and variables', () async { + final variables = {'agendaItemId': 'agenda1'}; + final mockResult = QueryResult( + options: QueryOptions(document: gql('')), + data: { + 'deleteAgendaItem': {'_id': 'agenda1'}, + }, + source: QueryResultSource.network, + ); + + when( + databaseFunctions.gqlAuthMutation( + EventQueries().deleteAgendaItem(), + variables: variables, + ), + ).thenAnswer((_) async => mockResult); + + final result = await EventService().deleteAgendaItem(variables); + + expect(result, equals(mockResult)); + verify( + databaseFunctions.gqlAuthMutation( + EventQueries().deleteAgendaItem(), + variables: variables, + ), + ).called(1); + }); + + test('updateAgendaItem sends correct mutation and variables', () async { + const itemId = 'agenda1'; + final variables = {'title': 'Updated Agenda'}; + final mockResult = QueryResult( + options: QueryOptions(document: gql('')), + data: { + 'updateAgendaItem': {'_id': 'agenda1', 'title': 'Updated Agenda'}, + }, + source: QueryResultSource.network, + ); + + when( + databaseFunctions.gqlAuthMutation( + EventQueries().updateAgendaItem(), + variables: { + 'updateAgendaItemId': itemId, + 'input': variables, + }, + ), + ).thenAnswer((_) async => mockResult); + + final result = await EventService().updateAgendaItem(itemId, variables); + + expect(result, equals(mockResult)); + verify( + databaseFunctions.gqlAuthMutation( + EventQueries().updateAgendaItem(), + variables: { + 'updateAgendaItemId': itemId, + 'input': variables, + }, + ), + ).called(1); + }); + + test('fetchAgendaItems returns correct data', () async { + const eventId = 'event123'; + final mockResult = QueryResult( + options: QueryOptions(document: gql('')), + data: { + 'fetchAgendaItems': [ + {'_id': 'agenda1', 'title': 'Agenda Item 1'}, + {'_id': 'agenda2', 'title': 'Agenda Item 2'}, + ], + }, + source: QueryResultSource.network, + ); + + when( + databaseFunctions.gqlAuthQuery( + EventQueries().fetchAgendaItemsByEvent(eventId), + ), + ).thenAnswer((_) async => mockResult); + + final result = await EventService().fetchAgendaItems(eventId); + + expect(result, equals(mockResult)); + verify( + databaseFunctions.gqlAuthQuery( + EventQueries().fetchAgendaItemsByEvent(eventId), + ), + ).called(1); + }); }); } diff --git a/test/utils/event_queries_test.dart b/test/utils/event_queries_test.dart index 2de587fab..81572bd8e 100644 --- a/test/utils/event_queries_test.dart +++ b/test/utils/event_queries_test.dart @@ -284,5 +284,130 @@ mutation CreateEventVolunteer(\$data: EventVolunteerInput!) { expected.replaceAll(' ', '').replaceAll('\n', '').replaceAll('\t', ''), ); }); + + test("Check if fetchAgendaItemCategoriesByOrganization works correctly", + () { + const expected = """ + query { + agendaItemCategoriesByOrganization(organizationId: "sampleOrgId") { + _id + name + description + + } + } + """; + + final actual = + EventQueries().fetchAgendaItemCategoriesByOrganization("sampleOrgId"); + expect(actual.trim(), expected.trim()); + }); + + test("Check if createAgendaItem works correctly", () { + const expected = """ + mutation CreateAgendaItem(\$input: CreateAgendaItemInput!) { + createAgendaItem(input: \$input) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + } + } + """; + + final actual = EventQueries().createAgendaItem(); + expect(actual.trim(), expected.trim()); + }); + + test("Check if updateAgendaItem works correctly", () { + const expected = """ + mutation UpdateAgendaItem(\$updateAgendaItemId: ID! + \$input: UpdateAgendaItemInput! + ) { + updateAgendaItem(id: \$updateAgendaItemId, input: \$input) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + } + } + """; + + final actual = EventQueries().updateAgendaItem(); + expect(actual.trim(), expected.trim()); + }); + + test("Check if deleteAgendaItem works correctly", () { + const expected = """ + mutation RemoveAgendaItem(\$removeAgendaItemId: ID!) { + removeAgendaItem(id: \$removeAgendaItemId) { + _id + } + } + """; + + final actual = EventQueries().deleteAgendaItem(); + expect(actual.trim(), expected.trim()); + }); + + test("Check if fetchAgendaItemsByEvent works correctly", () { + const expected = """ + query { + agendaItemByEvent(relatedEventId: "sampleEventId") { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + categories { + _id + name + } + sequence + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } + """; + + final actual = EventQueries().fetchAgendaItemsByEvent("sampleEventId"); + expect(actual.trim(), expected.trim()); + }); }); } diff --git a/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/edit_agenda_view_model_test.dart b/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/edit_agenda_view_model_test.dart new file mode 100644 index 000000000..d9c27e659 --- /dev/null +++ b/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/edit_agenda_view_model_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +void main() { + late EditAgendaItemViewModel model; + + final testAgendaItem = EventAgendaItem( + id: '1', + title: 'Test Agenda Item', + description: 'Test Description', + duration: '60', + urls: ['https://example.com'], + attachments: ['base64image1'], + categories: [AgendaCategory(id: 'cat1', name: 'Category 1')], + ); + + final testCategories = [ + AgendaCategory(id: 'cat1', name: 'Category 1'), + AgendaCategory(id: 'cat2', name: 'Category 2'), + ]; + + setUp(() { + registerServices(); + model = EditAgendaItemViewModel(); + }); + + group('EditAgendaItemViewModel Tests -', () { + test('initialize() populates the form correctly', () { + model.initialize(testAgendaItem, testCategories); + + expect(model.titleController.text, 'Test Agenda Item'); + expect(model.descriptionController.text, 'Test Description'); + expect(model.durationController.text, '60'); + expect(model.urls, ['https://example.com']); + expect(model.attachments, ['base64image1']); + expect(model.selectedCategories.length, 1); + expect(model.selectedCategories[0].id, 'cat1'); + }); + + test('setSelectedCategories() updates selected categories', () { + model.initialize(testAgendaItem, testCategories); + model.setSelectedCategories([testCategories[1]]); + + expect(model.selectedCategories.length, 1); + expect(model.selectedCategories[0].id, 'cat2'); + }); + + test('addUrl() adds a new URL', () { + model.initialize(testAgendaItem, testCategories); + model.addUrl('https://newexample.com'); + + expect(model.urls.length, 2); + expect(model.urls.contains('https://newexample.com'), true); + }); + + test('removeUrl() removes a URL', () { + model.initialize(testAgendaItem, testCategories); + model.removeUrl('https://example.com'); + + expect(model.urls.length, 0); + }); + + test('removeAttachment() removes an attachment', () { + model.initialize(testAgendaItem, testCategories); + model.removeAttachment('base64image1'); + + expect(model.attachments.length, 0); + }); + + test('checkForChanges() detects changes correctly', () { + model.initialize(testAgendaItem, testCategories); + expect(model.checkForChanges(), false); + + model.titleController.text = 'Updated Title'; + expect(model.checkForChanges(), true); + }); + + testWidgets('updateAgendaItem() calls event service with correct data', + (WidgetTester tester) async { + model.initialize(testAgendaItem, testCategories); + model.titleController.text = 'Updated Title'; + + when( + eventService.updateAgendaItem('1', { + 'title': model.titleController.text, + }), + ).thenAnswer( + (_) async => QueryResult( + source: QueryResultSource.network, + data: { + 'updateAgendaItem': {'id': '1'}, + }, + options: QueryOptions(document: gql('')), + ), + ); + + await model.updateAgendaItem(); + + verify( + eventService.updateAgendaItem('1', { + 'title': 'Updated Title', + 'description': 'Test Description', + 'duration': '60', + 'attachments': ['base64image1'], + 'urls': ['https://example.com'], + 'categories': ['cat1'], + }), + ).called(1); + }); + }); +} diff --git a/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/event_info_view_model_test.dart b/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/event_info_view_model_test.dart index fd0a2c510..9011569e9 100644 --- a/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/event_info_view_model_test.dart +++ b/test/view_model_tests/after_auth_view_model_tests/event_view_model_tests/event_info_view_model_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:mockito/mockito.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; import 'package:talawa/models/events/event_model.dart'; import 'package:talawa/models/events/event_volunteer_group.dart'; import 'package:talawa/services/graphql_config.dart'; @@ -182,5 +183,182 @@ void main() { expect(model.volunteerGroups.length, 0); }); + + test('createAgendaItem success', () async { + final Event event1 = Event(id: "1"); + model.event = event1; + + final eventService = getAndRegisterEventService(); + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'createAgendaItem': { + 'id': '1', + 'title': 'Test Agenda', + 'duration': '1h', + 'sequence': 1, + }, + }, + options: QueryOptions(document: gql(EventQueries().createAgendaItem())), + ); + + when( + eventService.createAgendaItem({ + 'title': 'Test Agenda', + 'sequence': 1, + 'description': 'desc', + 'duration': '1h', + 'organizationId': 'XYZ', + 'attachments': [], + 'relatedEventId': model.event.id, + 'urls': [], + 'categories': ['cat1'], + }), + ).thenAnswer((_) async => mockResult); + + final result = await model.createAgendaItem( + title: 'Test Agenda', + duration: '1h', + attachments: [], + categories: ['cat1'], + description: 'desc', + sequence: 1, + urls: [], + ); + + expect(result, isNotNull); + expect(result!.title, 'Test Agenda'); + expect(model.agendaItems.length, 1); + expect(model.agendaItems.first.title, 'Test Agenda'); + }); + test('deleteAgendaItem success', () async { + final Event event1 = Event(id: "1"); + model.event = event1; + + final eventService = getAndRegisterEventService(); + model.agendaItems.clear(); + model.agendaItems.addAll([ + EventAgendaItem(id: '1', title: 'Item 1'), + EventAgendaItem(id: '2', title: 'Item 2'), + ]); + + when(eventService.deleteAgendaItem({"removeAgendaItemId": '1'})) + .thenAnswer((_) async => true); + + await model.deleteAgendaItem('1'); + + expect(model.agendaItems.length, 1); + expect(model.agendaItems.first.id, '2'); + }); + + test('updateAgendaItemSequence success', () async { + final Event event1 = Event(id: "1"); + model.event = event1; + + final eventService = getAndRegisterEventService(); + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'updateAgendaItem': { + 'id': '1', + 'title': 'Updated Item', + 'sequence': 2, + }, + }, + options: QueryOptions(document: gql(EventQueries().updateAgendaItem())), + ); + model.agendaItems.clear(); + model.agendaItems.addAll([ + EventAgendaItem(id: '1', title: 'Item 1', sequence: 1), + EventAgendaItem(id: '2', title: 'Item 2', sequence: 2), + ]); + + when( + eventService.updateAgendaItem( + '1', + {'sequence': 2}, + ), + ).thenAnswer((_) async => mockResult); + + await model.updateAgendaItemSequence('1', 2); + + expect(model.agendaItems.first.sequence, 2); + expect(model.agendaItems.first.title, 'Updated Item'); + }); + test('fetchAgendaItems success', () async { + final Event event1 = Event(id: "1"); + model.event = event1; + + final eventService = getAndRegisterEventService(); + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemByEvent': [ + { + 'id': '1', + 'title': 'Agenda 1', + 'duration': '1h', + 'sequence': 1, + }, + { + 'id': '2', + 'title': 'Agenda 2', + 'duration': '30m', + 'sequence': 2, + }, + ], + }, + options: QueryOptions( + document: gql(EventQueries().fetchAgendaItemsByEvent('1')), + ), + ); + + when(eventService.fetchAgendaItems('1')) + .thenAnswer((_) async => mockResult); + + await model.fetchAgendaItems(); + + expect(model.agendaItems.length, 2); + expect(model.agendaItems[0].title, 'Agenda 1'); + expect(model.agendaItems[1].title, 'Agenda 2'); + expect(model.agendaItems[0].sequence, 1); + expect(model.agendaItems[1].sequence, 2); + }); + + test('fetchCategories success', () async { + final Event event1 = Event(id: "1"); + model.event = event1; + + final eventService = getAndRegisterEventService(); + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemCategoriesByOrganization': [ + { + 'id': '1', + 'name': 'Category 1', + }, + { + 'id': '2', + 'name': 'Category 2', + }, + ], + }, + options: QueryOptions( + document: gql( + EventQueries().fetchAgendaItemCategoriesByOrganization('XYZ'), + ), + ), + ); + + when(eventService.fetchAgendaCategories("XYZ")) + .thenAnswer((_) async => mockResult); + + await model.fetchCategories(); + + expect(model.categories.length, 2); + expect(model.categories[0].name, 'Category 1'); + expect(model.categories[1].name, 'Category 2'); + }); }); } diff --git a/test/views/after_auth_screens/events/create_agenda_item_page_test.dart b/test/views/after_auth_screens/events/create_agenda_item_page_test.dart new file mode 100644 index 000000000..0e198474a --- /dev/null +++ b/test/views/after_auth_screens/events/create_agenda_item_page_test.dart @@ -0,0 +1,217 @@ +// ignore_for_file: talawa_api_doc +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_model.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/navigation_service.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/utils/event_queries.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; +import 'package:talawa/view_model/lang_view_model.dart'; +import 'package:talawa/views/after_auth_screens/events/create_agenda_item_page.dart'; +import 'package:talawa/views/base_view.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +Event getTestEvent({ + bool isPublic = false, + bool viewOnMap = true, + bool asAdmin = false, +}) { + return Event( + id: "1", + title: "test_event", + creator: User( + id: asAdmin ? "xzy1" : "acb1", + firstName: "ravidi", + lastName: "shaikh", + ), + isPublic: isPublic, + startDate: "00/00/0000", + endDate: "12/12/9999", + startTime: "00:00", + endTime: "24:00", + location: "iitbhu, varanasi", + description: "test_event_description", + admins: [ + User( + firstName: "ravidi_admin_one", + lastName: "shaikh_admin_one", + ), + User( + firstName: "ravidi_admin_two", + lastName: "shaikh_admin_two", + ), + ], + attendees: [ + Attendee( + id: "1", + firstName: "Test", + lastName: "User", + ), + ], + isRegisterable: true, + ); +} + +Widget createCreateAgendaItemScreen() { + return BaseView( + onModelReady: (model) => model.initialize(), + builder: (context, langModel, child) { + return BaseView( + onModelReady: (model) { + model.initialize( + args: { + "event": getTestEvent( + isPublic: true, + viewOnMap: false, + asAdmin: true, + ), + "exploreEventViewModel": ExploreEventsViewModel(), + }, + ); + }, + builder: (context, model, child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: CreateAgendaItemPage( + model: model, + ), + ), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); + }, + ); + }, + ); +} + +List testCategories = [ + AgendaCategory(id: '1', name: 'Category 1'), + AgendaCategory(id: '2', name: 'Category 2'), +]; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + testSetupLocator(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('CreateAgendaItemPage Widget Tests', () { + testWidgets("Check if create agenda item screen shows up", (tester) async { + await tester.pumpWidget(createCreateAgendaItemScreen()); + await tester.pumpAndSettle(); + + expect(find.byType(CreateAgendaItemPage), findsOneWidget); + }); + + testWidgets('Category selection works correctly', + (WidgetTester tester) async { + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemCategoriesByOrganization': [ + { + 'id': '1', + 'name': 'Category 1', + }, + { + 'id': '2', + 'name': 'Category 2', + }, + ], + }, + options: QueryOptions( + document: gql( + EventQueries().fetchAgendaItemCategoriesByOrganization('XYZ'), + ), + ), + ); + + when(eventService.fetchAgendaCategories("XYZ")) + .thenAnswer((_) async => mockResult); + await tester.pumpWidget(createCreateAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Category 1').last); + await tester.pumpAndSettle(); + + expect(find.byType(Chip), findsOneWidget); + expect(find.text('Category 1'), findsNWidgets(2)); + }); + + testWidgets('Add button works correctly', (WidgetTester tester) async { + await tester.pumpWidget(createCreateAgendaItemScreen()); + await tester.pumpAndSettle(); + + // Fill in the required fields + await tester.enterText( + find.byKey(const Key('create_event_agenda_tf1')), + 'Test Agenda Item', + ); + await tester.enterText( + find.byKey(const Key('create_event_agenda_tf2')), + 'Test Description', + ); + await tester.enterText( + find.byKey(const Key('create_event_agenda_duration')), + '00:30', + ); + + await tester.tap(find.byKey(const Key('add_agenda'))); + await tester.pumpAndSettle(); + + expect(find.byType(CreateAgendaItemPage), findsNothing); + }); + + testWidgets('Add URL works correctly', (WidgetTester tester) async { + await tester.pumpWidget(createCreateAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField).at(3), + 'https://example.com', + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('add_url')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Chip), findsOneWidget); + expect(find.text('https://example.com'), findsOneWidget); + }); + + testWidgets('Add Attachments button is present', + (WidgetTester tester) async { + await tester.pumpWidget(createCreateAgendaItemScreen()); + await tester.pumpAndSettle(); + + expect(find.text('Add Attachments'), findsOneWidget); + }); + }); +} diff --git a/test/views/after_auth_screens/events/edit_agenda_item_page_test.dart b/test/views/after_auth_screens/events/edit_agenda_item_page_test.dart new file mode 100644 index 000000000..d27386233 --- /dev/null +++ b/test/views/after_auth_screens/events/edit_agenda_item_page_test.dart @@ -0,0 +1,143 @@ +// ignore_for_file: talawa_api_doc +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/navigation_service.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/edit_agenda_view_model.dart'; +import 'package:talawa/view_model/lang_view_model.dart'; +import 'package:talawa/views/after_auth_screens/events/edit_agenda_item_page.dart'; +import 'package:talawa/views/base_view.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +Widget createEditAgendaItemScreen() { + return BaseView( + onModelReady: (model) => model.initialize(), + builder: (context, langModel, child) { + return BaseView( + onModelReady: (model) { + model.initialize(testAgendaItem, testCategories); + }, + builder: (context, model, child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: EditAgendaItemPage( + agendaItem: testAgendaItem, + categories: testCategories, + ), + ), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); + }, + ); + }, + ); +} + +EventAgendaItem testAgendaItem = EventAgendaItem( + id: '1', + title: 'Test Agenda Item', + description: 'Test Description', + duration: '00:30', + categories: [], + attachments: [], + urls: [], +); + +List testCategories = [ + AgendaCategory(id: '1', name: 'Category 1'), + AgendaCategory(id: '2', name: 'Category 2'), +]; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + testSetupLocator(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('EditAgendaItemPage Widget Tests', () { + testWidgets("Check if edit agenda item screen shows up", (tester) async { + await tester.pumpWidget(createEditAgendaItemScreen()); + await tester.pumpAndSettle(); + + expect(find.byType(EditAgendaItemPage), findsOneWidget); + }); + + testWidgets('Category selection works correctly', + (WidgetTester tester) async { + await tester.pumpWidget(createEditAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Category 1').last); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('Category 1')), findsOneWidget); + }); + + testWidgets('Update button works correctly', (WidgetTester tester) async { + await tester.pumpWidget(createEditAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + expect(find.text("No changes made"), findsOneWidget); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Category 1').last); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(find.byType(EditAgendaItemPage), findsNothing); + }); + + testWidgets('Add URL works correctly', (WidgetTester tester) async { + await tester.pumpWidget(createEditAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('urlTextField')), + 'https://example.com', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('https://example.com')), findsOneWidget); + }); + + testWidgets('Add Attachments button works correctly', + (WidgetTester tester) async { + await tester.pumpWidget(createEditAgendaItemScreen()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add Attachments')); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/test/views/after_auth_screens/events/manage_agenda_items_screen_test.dart b/test/views/after_auth_screens/events/manage_agenda_items_screen_test.dart new file mode 100644 index 000000000..ef8d28206 --- /dev/null +++ b/test/views/after_auth_screens/events/manage_agenda_items_screen_test.dart @@ -0,0 +1,248 @@ +// ignore_for_file: talawa_api_doc +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:talawa/models/events/event_model.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/navigation_service.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/utils/event_queries.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/event_info_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; +import 'package:talawa/view_model/lang_view_model.dart'; +import 'package:talawa/views/after_auth_screens/events/create_agenda_item_page.dart'; +import 'package:talawa/views/after_auth_screens/events/manage_agenda_items_screen.dart'; +import 'package:talawa/views/base_view.dart'; +import 'package:talawa/widgets/agenda_item_tile.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +Event getTestEvent({ + bool isPublic = false, + bool viewOnMap = true, + bool asAdmin = false, +}) { + return Event( + id: "1", + title: "test_event", + creator: User( + id: asAdmin ? "xzy1" : "acb1", + firstName: "ravidi", + lastName: "shaikh", + ), + isPublic: isPublic, + startDate: "00/00/0000", + endDate: "12/12/9999", + startTime: "00:00", + endTime: "24:00", + location: "iitbhu, varanasi", + description: "test_event_description", + admins: [ + User( + firstName: "ravidi_admin_one", + lastName: "shaikh_admin_one", + ), + User( + firstName: "ravidi_admin_two", + lastName: "shaikh_admin_two", + ), + ], + attendees: [ + Attendee( + id: "1", + firstName: "Test", + lastName: "User", + ), + ], + isRegisterable: true, + ); +} + +Widget createManageAgendaScreen() { + return BaseView( + onModelReady: (model) => model.initialize(), + builder: (context, langModel, child) { + return BaseView( + onModelReady: (model) { + model.initialize( + args: { + "event": getTestEvent( + isPublic: true, + viewOnMap: false, + asAdmin: true, + ), + "exploreEventViewModel": ExploreEventsViewModel(), + }, + ); + }, + builder: (context, model, child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: const Scaffold( + body: ManageAgendaScreen(), + ), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); + }, + ); + }, + ); +} + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + testSetupLocator(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('ManageAgendaScreen Widget Tests', () { + testWidgets('Shows empty state when no agenda items', + (WidgetTester tester) async { + await tester.pumpWidget(createManageAgendaScreen()); + await tester.pumpAndSettle(); + + expect(find.text('No agenda items yet'), findsOneWidget); + expect(find.byType(ReorderableListView), findsNothing); + }); + + testWidgets('Shows list of agenda items when present', + (WidgetTester tester) async { + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemByEvent': [ + { + 'id': '1', + 'title': 'Agenda 1', + 'duration': '1h', + 'sequence': 1, + }, + { + 'id': '2', + 'title': 'Agenda 2', + 'duration': '30m', + 'sequence': 2, + }, + ], + }, + options: QueryOptions( + document: gql(EventQueries().fetchAgendaItemsByEvent('1')), + ), + ); + + when(eventService.fetchAgendaItems('1')) + .thenAnswer((_) async => mockResult); + + await tester.pumpWidget(createManageAgendaScreen()); + await tester.pumpAndSettle(); + + expect(find.text('No agenda items yet'), findsNothing); + expect(find.byType(ReorderableListView), findsOneWidget); + expect(find.byType(ExpandableAgendaItemTile), findsNWidgets(2)); + }); + + testWidgets('Can reorder agenda items', (WidgetTester tester) async { + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemByEvent': [ + { + '_id': '1', + 'title': 'Agenda 1', + 'duration': '1h', + 'sequence': 1, + }, + { + '_id': '2', + 'title': 'Agenda 2', + 'duration': '30m', + 'sequence': 2, + }, + ], + }, + options: QueryOptions( + document: gql(EventQueries().fetchAgendaItemsByEvent('1')), + ), + ); + + when(eventService.fetchAgendaItems('1')) + .thenAnswer((_) async => mockResult); + + await tester.pumpWidget(createManageAgendaScreen()); + await tester.pumpAndSettle(); + + final firstItemFinder = find.text('Agenda 1'); + final secondItemFinder = find.text('Agenda 2'); + + expect(firstItemFinder, findsOneWidget); + expect(secondItemFinder, findsOneWidget); + + await tester.drag( + find.byKey(const Key('reorder_icon')).first, + const Offset(0, 200), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('Can delete agenda item', (WidgetTester tester) async { + final mockResult = QueryResult( + source: QueryResultSource.network, + data: { + 'agendaItemByEvent': [ + { + '_id': '1', + 'title': 'Agenda 1', + 'duration': '1h', + 'sequence': 1, + }, + { + '_id': '2', + 'title': 'Agenda 2', + 'duration': '30m', + 'sequence': 2, + }, + ], + }, + options: QueryOptions( + document: gql(EventQueries().fetchAgendaItemsByEvent('1')), + ), + ); + + when(eventService.fetchAgendaItems('1')) + .thenAnswer((_) async => mockResult); + when(eventService.deleteAgendaItem({"removeAgendaItemId": '1'})) + .thenAnswer((_) async => true); + await tester.pumpWidget(createManageAgendaScreen()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key("delete_agenda_item1"))); + await tester.pump(); + }); + + testWidgets('Can navigate to CreateAgendaItemPage', + (WidgetTester tester) async { + await tester.pumpWidget(createManageAgendaScreen()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('add_item_btn'))); + await tester.pumpAndSettle(); + + expect(find.byType(CreateAgendaItemPage), findsOneWidget); + }); + }); +} diff --git a/test/views/helpers/test_locator.dart b/test/views/helpers/test_locator.dart deleted file mode 100644 index b5e770e58..000000000 --- a/test/views/helpers/test_locator.dart +++ /dev/null @@ -1,4 +0,0 @@ -// ignore_for_file: talawa_api_doc -// ignore_for_file: talawa_good_doc_comments - -// TODO Implement this library. diff --git a/test/widget_tests/after_auth_screens/events/event_info_page_test.dart b/test/widget_tests/after_auth_screens/events/event_info_page_test.dart index 1843a6754..25bc32deb 100644 --- a/test/widget_tests/after_auth_screens/events/event_info_page_test.dart +++ b/test/widget_tests/after_auth_screens/events/event_info_page_test.dart @@ -12,6 +12,7 @@ import 'package:talawa/utils/app_localization.dart'; import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart'; import 'package:talawa/views/after_auth_screens/events/event_info_body.dart'; import 'package:talawa/views/after_auth_screens/events/event_info_page.dart'; +import 'package:talawa/views/after_auth_screens/events/manage_agenda_items_screen.dart'; import 'package:talawa/views/after_auth_screens/events/volunteer_groups_screen.dart'; import '../../../helpers/test_helpers.dart'; @@ -152,5 +153,32 @@ void main() { expect(find.byType(VolunteerGroupsScreen), findsOneWidget); }); }); + testWidgets('Test if agenda section appears on swipe left', (tester) async { + mockNetworkImages(() async { + await tester.pumpWidget(createEventInfoPage(true, true)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key("tabBar")), findsOneWidget); + expect(find.text('Info'), findsOneWidget); + expect(find.byType(VolunteerGroupsScreen), findsNothing); + + await tester.drag( + find.byType(TabBarView), + const Offset(-500.0, 0.0), + ); + await tester.pumpAndSettle(); + + expect(find.byType(EventInfoBody), findsNothing); + expect(find.byType(VolunteerGroupsScreen), findsOneWidget); + + await tester.drag( + find.byType(TabBarView), + const Offset(-500.0, 0.0), + ); + await tester.pumpAndSettle(); + + expect(find.byType(VolunteerGroupsScreen), findsNothing); + expect(find.byType(ManageAgendaScreen), findsOneWidget); + }); + }); }); } diff --git a/test/widget_tests/after_auth_screens/events/time_conversion_test.dart b/test/widget_tests/after_auth_screens/events/time_conversion_test.dart new file mode 100644 index 000000000..435375942 --- /dev/null +++ b/test/widget_tests/after_auth_screens/events/time_conversion_test.dart @@ -0,0 +1,162 @@ +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/utils/time_conversion.dart'; + +import '../../../helpers/test_helpers.dart'; + +void main() { + group('Time Conversion Utils', () { + setUp(() { + registerServices(); + }); + + tearDown(() { + unregisterServices(); + }); + + test('combineDateTime combines date and time correctly', () { + expect(combineDateTime('2023-05-01', '14:30:00'), '2023-05-01 14:30:00'); + }); + + test('splitDateTimeUTC splits UTC datetime correctly', () { + final result = splitDateTimeUTC('2023-05-01T14:30:00.000Z'); + expect(result['date'], '2023-05-01'); + expect(result['time'], '14:30:00.000Z'); + }); + + test('splitDateTimeUTC returns empty map for invalid input', () { + final result = splitDateTimeUTC('invalid-datetime'); + expect(result, isEmpty); + }); + + test('splitDateTimeLocal splits local datetime correctly', () { + final result = splitDateTimeLocal('2023-05-01T14:30:00.000'); + expect(result['date'], '2023-05-01'); + expect(result['time'], '14:30'); + }); + + test('splitDateTimeLocal returns empty map for invalid input', () { + final result = splitDateTimeLocal('invalid-datetime'); + expect(result, isEmpty); + }); + + test('convertUTCToLocal converts UTC to local time', () { + const utcTime = '2023-05-01T14:30:00.000Z'; + final localTime = convertUTCToLocal(utcTime); + expect(localTime, isNot(equals(utcTime))); + expect( + localTime, + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$'), + ); + }); + + test('convertUTCToLocal returns empty string for invalid input', () { + final localTime = convertUTCToLocal('invalid-datetime'); + expect(localTime, isEmpty); + }); + + test('convertLocalToUTC converts local to UTC time', () { + const localTime = '2023-05-01T14:30:00.000'; + final utcTime = convertLocalToUTC(localTime); + expect(utcTime, isNot(equals(localTime))); + expect( + utcTime, + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'), + ); + }); + + test('convertLocalToUTC returns empty string for invalid input', () { + final utcTime = convertLocalToUTC('invalid-datetime'); + expect(utcTime, isEmpty); + }); + + group('traverseAndConvertDates', () { + test('converts direct fields', () { + final testObj = { + 'createdAt': '2023-05-01T14:30:00.000Z', + 'name': 'Test', + }; + traverseAndConvertDates(testObj, convertUTCToLocal, splitDateTimeLocal); + expect(testObj['createdAt'], isNot(equals('2023-05-01T14:30:00.000Z'))); + expect( + testObj['createdAt'], + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$'), + ); + }); + + test('converts paired fields', () { + final testObj = { + 'startDate': '2023-05-01', + 'startTime': '14:30:00', + 'name': 'Test', + }; + traverseAndConvertDates(testObj, convertUTCToLocal, splitDateTimeLocal); + expect(testObj['startDate'], '2023-05-01'); + expect(testObj['startTime'], matches(r'^\d{2}:\d{2}$')); + }); + + test('handles invalid date/time in traverseAndConvertDates', () { + final testObj = { + 'createdAt': 'invalid-datetime', + 'startDate': 'invalid-date', + 'startTime': 'invalid-time', + 'name': 'Test', + }; + traverseAndConvertDates(testObj, convertUTCToLocal, splitDateTimeLocal); + expect(testObj['createdAt'], isEmpty); + expect(testObj['startDate'], isEmpty); + expect(testObj['startTime'], isEmpty); + }); + + test('converts nested objects', () { + final testObj = { + 'user': { + 'createdAt': '2023-05-01T14:30:00.000Z', + 'name': 'Test User', + }, + }; + traverseAndConvertDates(testObj, convertUTCToLocal, splitDateTimeLocal); + expect( + testObj['user']?['createdAt'], + isNot(equals('2023-05-01T14:30:00.000Z')), + ); + expect( + testObj['user']?['createdAt'], + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$'), + ); + }); + + test('converts objects in lists', () { + withClock(Clock.fixed(DateTime.utc(2023, 5, 1, 12, 0)), () { + final testObj = { + 'items': [ + {'createdAt': '2023-05-01T14:30:00.000Z'}, + {'createdAt': '2023-05-02T15:45:00.000Z'}, + ], + }; + traverseAndConvertDates( + testObj, + convertUTCToLocal, + splitDateTimeLocal, + ); + expect( + testObj['items']?[0]['createdAt'], + isNot(equals('2023-05-01T14:30:00.000Z')), + ); + expect( + testObj['items']?[0]['createdAt'], + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$'), + ); + expect( + testObj['items']?[1]['createdAt'], + isNot(equals('2023-05-02T15:45:00.000Z')), + ); + expect( + testObj['items']?[1]['createdAt'], + matches(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$'), + ); + }); + }); + }); + }); +} diff --git a/test/widget_tests/widgets/agenda_item_tile_test.dart b/test/widget_tests/widgets/agenda_item_tile_test.dart new file mode 100644 index 000000000..8ff3cf6d4 --- /dev/null +++ b/test/widget_tests/widgets/agenda_item_tile_test.dart @@ -0,0 +1,136 @@ +// ignore_for_file: talawa_api_doc +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/events/event_agenda_category.dart'; +import 'package:talawa/models/events/event_agenda_item.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/widgets/agenda_item_tile.dart'; + +void main() { + late EventAgendaItem testAgendaItem; + SizeConfig().test(); + setUp(() { + testAgendaItem = EventAgendaItem( + id: '1', + title: 'Test Agenda Item', + description: 'Test Description', + duration: '00:30', + sequence: 1, + categories: [ + AgendaCategory(id: '1', name: 'Category 1'), + AgendaCategory(id: '2', name: 'Category 2'), + ], + attachments: [], + urls: ['https://example.com'], + ); + }); + + Widget createExpandableAgendaItemTile() { + return MaterialApp( + home: Scaffold( + body: ExpandableAgendaItemTile( + item: testAgendaItem, + onEdit: () {}, + onDelete: () {}, + ), + ), + ); + } + + group('ExpandableAgendaItemTile Widget Tests', () { + testWidgets('ExpandableAgendaItemTile renders correctly', + (WidgetTester tester) async { + await tester.pumpWidget(createExpandableAgendaItemTile()); + await tester.pumpAndSettle(); + + expect(find.byType(ExpandableAgendaItemTile), findsOneWidget); + expect(find.text('Test Agenda Item'), findsOneWidget); + expect(find.byIcon(Icons.drag_handle), findsOneWidget); + expect(find.byIcon(Icons.edit), findsOneWidget); + expect(find.byIcon(Icons.delete), findsOneWidget); + }); + + testWidgets('Expansion works correctly', (WidgetTester tester) async { + await tester.pumpWidget(createExpandableAgendaItemTile()); + await tester.pumpAndSettle(); + + // Initially, the expanded content should not be visible + expect(find.text('Description:'), findsNothing); + + // Tap to expand + await tester.tap(find.text('Test Agenda Item')); + await tester.pumpAndSettle(); + + // Now the expanded content should be visible + expect(find.text('Description:'), findsOneWidget); + }); + + testWidgets('Categories are displayed correctly', + (WidgetTester tester) async { + await tester.pumpWidget(createExpandableAgendaItemTile()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Test Agenda Item')); + await tester.pumpAndSettle(); + + expect(find.text('Categories:'), findsOneWidget); + expect(find.text('Category 1'), findsOneWidget); + expect(find.text('Category 2'), findsOneWidget); + }); + + testWidgets('URLs are displayed correctly', (WidgetTester tester) async { + await tester.pumpWidget(createExpandableAgendaItemTile()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Test Agenda Item')); + await tester.pumpAndSettle(); + + expect(find.text('URLs:'), findsOneWidget); + expect(find.text('https://example.com'), findsOneWidget); + }); + + testWidgets('Edit button calls onEdit callback', + (WidgetTester tester) async { + bool editCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ExpandableAgendaItemTile( + item: testAgendaItem, + onEdit: () => editCalled = true, + onDelete: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + + expect(editCalled, isTrue); + }); + + testWidgets('Delete button calls onDelete callback', + (WidgetTester tester) async { + bool deleteCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ExpandableAgendaItemTile( + item: testAgendaItem, + onEdit: () {}, + onDelete: () => deleteCalled = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete)); + await tester.pumpAndSettle(); + + expect(deleteCalled, isTrue); + }); + }); +}