diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5996483f2..a57e0b1a3 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 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 + 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 lib/views/after_auth_screens/funds/fundraising_campaigns_screen.dart lib/views/after_auth_screens/funds/fund_pledges_screen.dart lib/widgets/update_pledge_dialogue_box.dart - name: setup python uses: actions/setup-python@v5 - name: Check for presence of ignore directives corresponding to custom lints diff --git a/lib/locator.dart b/lib/locator.dart index 7a494c893..ec89ae3fe 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -8,6 +8,7 @@ import 'package:talawa/services/chat_service.dart'; import 'package:talawa/services/comment_service.dart'; import 'package:talawa/services/database_mutation_functions.dart'; import 'package:talawa/services/event_service.dart'; +import 'package:talawa/services/fund_service.dart'; import 'package:talawa/services/graphql_config.dart'; import 'package:talawa/services/image_service.dart'; import 'package:talawa/services/navigation_service.dart'; @@ -33,6 +34,7 @@ import 'package:talawa/view_model/after_auth_view_models/event_view_models/event 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/event_view_models/manage_volunteer_group_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/feed_view_models/organization_feed_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/profile_view_models/edit_profile_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/profile_view_models/profile_page_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/settings_view_models/app_setting_view_model.dart'; @@ -90,6 +92,9 @@ final imageService = locator(); ///GetIt for SessionManager. final sessionManager = locator(); +///GetIt for FundService. +final fundServcie = locator(); + ///GetIt for ActonHandlerService. final actionHandlerService = locator(); @@ -122,6 +127,7 @@ Future setupLocator() async { //Services locator.registerLazySingleton(() => PostService()); + locator.registerLazySingleton(() => FundService()); locator.registerLazySingleton(() => EventService()); locator.registerLazySingleton(() => CommentService()); locator.registerLazySingleton(() => OrganizationService()); @@ -151,6 +157,7 @@ Future setupLocator() async { locator.registerFactory(() => OrganizationFeedViewModel()); locator.registerFactory(() => SetUrlViewModel()); locator.registerFactory(() => LoginViewModel()); + locator.registerFactory(() => FundViewModel()); locator.registerFactory(() => ManageVolunteerGroupViewModel()); locator.registerFactory(() => EditAgendaItemViewModel()); locator.registerFactory(() => SelectOrganizationViewModel()); diff --git a/lib/models/funds/fund.dart b/lib/models/funds/fund.dart new file mode 100644 index 000000000..92974129f --- /dev/null +++ b/lib/models/funds/fund.dart @@ -0,0 +1,91 @@ +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/user/user_info.dart'; + +/// The `Fund` class represents a fund in the application. +class Fund { + /// Constructs a `Fund` instance. + /// + /// [id] is the unique identifier of the fund. + /// [organizationId] is the identifier of the organization to which the fund belongs. + /// [name] is the name of the fund. + /// [taxDeductible] indicates whether the fund is tax-deductible. + /// [isDefault] indicates whether the fund is the default fund. + /// [isArchived] indicates whether the fund is archived. + /// [creatorId] is the identifier of the user who created the fund. + /// [campaigns] is a list of campaign identifiers associated with the fund. + /// [createdAt] is the timestamp of when the fund was created. + /// [updatedAt] is the timestamp of when the fund was last updated. + Fund({ + this.id, + this.organizationId, + this.name, + this.taxDeductible, + this.isDefault, + this.isArchived, + this.creator, + this.campaigns, + this.createdAt, + this.updatedAt, + }); + + /// Creates a `Fund` instance from a JSON object. + /// + /// The [json] parameter is a map containing the fund data. + /// + /// Returns an instance of `Fund`. + factory Fund.fromJson(Map json) { + return Fund( + id: json['_id'] as String?, + organizationId: json['organizationId'] as String?, + name: json['name'] as String?, + taxDeductible: json['taxDeductible'] as bool?, + isDefault: json['isDefault'] as bool?, + isArchived: json['isArchived'] as bool?, + creator: json['creator'] == null + ? null + : User.fromJson( + json['creator'] as Map, + fromOrg: true, + ), + campaigns: (json['campaigns'] as List?) + ?.map((e) => e as Campaign) + .toList(), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// The unique identifier of the fund. + final String? id; + + /// The identifier of the organization to which the fund belongs. + final String? organizationId; + + /// The name of the fund. + final String? name; + + /// Indicates whether the fund is tax-deductible. + final bool? taxDeductible; + + /// Indicates whether the fund is the default fund. + final bool? isDefault; + + /// Indicates whether the fund is archived. + final bool? isArchived; + + /// The identifier of the user who created the fund. + final User? creator; + + /// A list of campaign identifiers associated with the fund. + final List? campaigns; + + /// The timestamp of when the fund was created. + final DateTime? createdAt; + + /// The timestamp of when the fund was last updated. + final DateTime? updatedAt; +} diff --git a/lib/models/funds/fund_campaign.dart b/lib/models/funds/fund_campaign.dart new file mode 100644 index 000000000..3d85aab4e --- /dev/null +++ b/lib/models/funds/fund_campaign.dart @@ -0,0 +1,90 @@ +import 'package:talawa/models/funds/fund_pledges.dart'; + +/// The `Campaign` class represents a fundraising campaign in the application. +class Campaign { + /// Constructs a `FundraisingCampaign` instance. + /// + /// [id] is the unique identifier of the campaign. + /// [fundId] is the identifier of the fund to which the campaign belongs. + /// [name] is the name of the campaign. + /// [startDate] is the start date of the campaign. + /// [endDate] is the end date of the campaign. + /// [fundingGoal] is the funding goal of the campaign. + /// [currency] is the currency used for the campaign. + /// [pledges] is a list of pledge identifiers associated with the campaign. + /// [createdAt] is the timestamp of when the campaign was created. + /// [updatedAt] is the timestamp of when the campaign was last updated. + Campaign({ + this.id, + this.fundId, + this.name, + this.startDate, + this.endDate, + this.fundingGoal, + this.currency, + this.pledges, + this.createdAt, + this.updatedAt, + }); + + /// Creates a `Campaign` instance from a JSON object. + /// + /// The [json] parameter is a map containing the campaign data. + /// + /// Returns an instance of `Campaign`. + factory Campaign.fromJson(Map json) { + return Campaign( + id: json['_id'] as String?, + fundId: json['fundId'] as String?, + name: json['name'] as String?, + startDate: json['startDate'] != null + ? DateTime.parse(json['startDate'] as String) + : null, + endDate: json['endDate'] != null + ? DateTime.parse(json['endDate'] as String) + : null, + fundingGoal: (json['fundingGoal'] is int) + ? (json['fundingGoal'] as int).toDouble() + : json['fundingGoal'] as double?, + currency: json['currency'] as String?, + pledges: + (json['pledges'] as List?)?.map((e) => e as Pledge).toList(), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// The unique identifier of the campaign. + final String? id; + + /// The identifier of the fund to which the campaign belongs. + final String? fundId; + + /// The name of the campaign. + final String? name; + + /// The start date of the campaign. + final DateTime? startDate; + + /// The end date of the campaign. + final DateTime? endDate; + + /// The funding goal of the campaign. + final double? fundingGoal; + + /// The currency used for the campaign. + final String? currency; + + /// A list of pledge identifiers associated with the campaign. + final List? pledges; + + /// The timestamp of when the campaign was created. + final DateTime? createdAt; + + /// The timestamp of when the campaign was last updated. + final DateTime? updatedAt; +} diff --git a/lib/models/funds/fund_pledges.dart b/lib/models/funds/fund_pledges.dart new file mode 100644 index 000000000..103d1e5c4 --- /dev/null +++ b/lib/models/funds/fund_pledges.dart @@ -0,0 +1,90 @@ +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/user/user_info.dart'; + +/// The `Pledge` class represents a pledge for a fundraising campaign in the application. +class Pledge { + /// Constructs a `Pledge` instance. + /// + /// [id] is the unique identifier of the pledge. + /// [campaigns] is a list of campaign identifiers associated with the pledge. + /// [users] is a list of user identifiers associated with the pledge. + /// [startDate] is the start date of the pledge. + /// [endDate] is the end date of the pledge. + /// [amount] is the amount pledged. + /// [currency] is the currency of the pledged amount. + /// [createdAt] is the timestamp of when the pledge was created. + /// [updatedAt] is the timestamp of when the pledge was last updated. + Pledge({ + this.id, + this.campaigns, + this.pledgers, + this.startDate, + this.endDate, + this.amount, + this.currency, + this.createdAt, + this.updatedAt, + }); + + /// Creates a `Pledge` instance from a JSON object. + /// + /// The [json] parameter is a map containing the pledge data. + /// + /// Returns an instance of `Pledge`. + factory Pledge.fromJson(Map json) { + return Pledge( + id: json['_id'] as String?, + campaigns: (json['campaigns'] as List?) + ?.map((e) => e as Campaign) + .toList(), + pledgers: json['users'] == null + ? null + : (json['users'] as List?) + ?.map( + (e) => User.fromJson(e as Map, fromOrg: true), + ) + .toList(), + startDate: json['startDate'] != null + ? DateTime.parse(json['startDate'] as String) + : null, + endDate: json['endDate'] != null + ? DateTime.parse(json['endDate'] as String) + : null, + amount: json['amount'] as int?, + currency: json['currency'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, + ); + } + + /// The unique identifier of the pledge. + final String? id; + + /// A list of campaign identifiers associated with the pledge. + final List? campaigns; + + /// A list of user identifiers associated with the pledge. + final List? pledgers; + + /// The start date of the pledge. + final DateTime? startDate; + + /// The end date of the pledge. + final DateTime? endDate; + + /// The amount pledged. + final int? amount; + + /// The currency of the pledged amount. + final String? currency; + + /// The timestamp of when the pledge was created. + final DateTime? createdAt; + + /// The timestamp of when the pledge was last updated. + final DateTime? updatedAt; +} diff --git a/lib/services/event_service.dart b/lib/services/event_service.dart index 25de549a0..730af1112 100644 --- a/lib/services/event_service.dart +++ b/lib/services/event_service.dart @@ -59,7 +59,7 @@ class EventService extends BaseFeedManager { final String currentOrgID = _currentOrg.id!; // mutation to fetch the events final String mutation = EventQueries().fetchOrgEvents(currentOrgID); - final result = await _dbFunctions.gqlAuthQuery(mutation); + final result = await _dbFunctions.gqlAuthMutation(mutation); if (result.data == null) { throw Exception('unable to fetch data'); diff --git a/lib/services/fund_service.dart b/lib/services/fund_service.dart new file mode 100644 index 000000000..c33218b0b --- /dev/null +++ b/lib/services/fund_service.dart @@ -0,0 +1,217 @@ +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/organization/org_info.dart'; +import 'package:talawa/services/database_mutation_functions.dart'; +import 'package:talawa/services/user_config.dart'; +import 'package:talawa/utils/fund_queries.dart'; + +/// FundService class provides different services in the context of Funds, Campaigns, and Pledges. +class FundService { + FundService() { + _currentOrg = _userConfig.currentOrg; + _fundQueries = FundQueries(); + } + + // variables declaration + final _userConfig = locator(); + final _dbFunctions = locator(); + late FundQueries _fundQueries; + + late OrgInfo _currentOrg; + + /// This function is used to fetch all funds of the organization. + /// + /// **params**: + /// * `orderBy`: This variable decides the sorting opton. + /// + /// **returns**: + /// * `Future>`: List of funds associated with the organization. + Future> getFunds({String orderBy = 'createdAt_DESC'}) async { + try { + final String currentOrgID = _currentOrg.id!; + final result = await _dbFunctions.gqlAuthQuery( + _fundQueries.fetchOrgFunds(), + variables: { + 'orgId': currentOrgID, + 'filter': '', + 'orderBy': orderBy, + }, + ); + + if (result.data == null) { + throw Exception('Unable to fetch funds'); + } + + final fundsJson = result.data!['fundsByOrganization'] as List; + final funds = fundsJson.map((fundData) { + if (fundData is Map) { + return Fund.fromJson(fundData); + } else { + throw Exception('Invalid fund data format'); + } + }).toList(); + + return funds; + } catch (e) { + print('Error fetching funss: $e'); + throw Exception('Failed to load Funds'); + } + } + + /// This function is used to fetch all campaigns of a fund. + /// + /// **params**: + /// * `fundId`: id of a fund. + /// * `orderBy`: This variable decides the sorting opton. + /// + /// **returns**: + /// * `Future>`: List of campaigns associated with the fund. + Future> getCampaigns( + String fundId, { + String orderBy = 'endDate_DESC', + }) async { + try { + final QueryResult result = await _dbFunctions.gqlAuthQuery( + _fundQueries.fetchCampaignsByFund(), + variables: { + 'fundId': fundId, + 'where': { + 'fundId': fundId, + }, + 'pledgeOrderBy': orderBy, + }, + ); + + if (result.data == null) { + throw Exception('Unable to fetch campaigns'); + } + + final campaignsJson = (result.data!['getFundById'] + as Map)['campaigns'] as List; + final campaigns = campaignsJson.map((campaignData) { + if (campaignData is Map) { + return Campaign.fromJson(campaignData); + } else { + throw Exception('Invalid campaign data format'); + } + }).toList(); + + return campaigns; + } catch (e) { + print('Error fetching campaigns: $e'); + throw Exception('Failed to load campaigns'); + } + } + + /// This function is used to fetch all pledges of a campaign. + /// + /// **params**: + /// * `campaignId`: id of a campaign. + /// * `orderBy`: This variable decides the sorting opton. + /// + /// **returns**: + /// * `Future>`: List of pledges associated with the campaign. + Future> getPledgesByCampaign( + String campaignId, { + String orderBy = 'endDate_DESC', + }) async { + try { + final QueryResult result = await _dbFunctions.gqlAuthQuery( + _fundQueries.fetchPledgesByCampaign(), + variables: { + 'where': { + 'id': campaignId, + }, + 'pledgeOrderBy': orderBy, + }, + ); + + if (result.data == null) { + throw Exception('Unable to fetch pledges'); + } + + final campaigns = + (result.data!['getFundraisingCampaigns'] as List) + .cast>(); + final pledgesJson = (campaigns[0]['pledges'] as List) + .cast>(); + + final pledges = pledgesJson.map((pledgeData) { + return Pledge.fromJson(pledgeData); + }).toList(); + return pledges; + } catch (e) { + print('Error fetching pledges: $e'); + throw Exception('Failed to load pledges'); + } + } + + /// This function is used to create a new pledge. + /// + /// **params**: + /// * `variables`: A map of key-value pairs representing the variables required for the GraphQL mutation. + /// + /// **returns**: + /// * `Future>`: which contains the result of the GraphQL mutation. + Future> createPledge( + Map variables, + ) async { + try { + final result = await _dbFunctions.gqlAuthMutation( + _fundQueries.createPledge(), + variables: variables, + ); + return result; + } catch (e) { + print(e); + throw Exception('Failed to load pledges'); + } + } + + /// This function is used to update an existing pledge. + /// + /// **params**: + /// * `variables`: A map of key-value pairs representing the variables required for the GraphQL mutation. + /// + /// **returns**: + /// * `Future>`: which contains the result of the GraphQL mutation. + Future> updatePledge( + Map variables, + ) async { + try { + final result = await _dbFunctions.gqlAuthMutation( + _fundQueries.updatePledge(), + variables: variables, + ); + return result; + } catch (e) { + print(e); + throw Exception('Failed to load pledges'); + } + } + + /// This function is used to delete a pledge. + /// + /// **params**: + /// * `pledgeId`: id of the pledge to be deleted. + /// + /// **returns**: + /// * `Future>`: which contains the result of the GraphQL mutation. + Future> deletePledge(String pledgeId) async { + try { + final result = await _dbFunctions.gqlAuthMutation( + _fundQueries.deletePledge(), + variables: { + 'id': pledgeId, + }, + ); + return result; + } catch (e) { + print(e); + throw Exception('Failed to load pledges'); + } + } +} diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart index b8a2a1a90..7836d1453 100644 --- a/lib/services/image_service.dart +++ b/lib/services/image_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_cropper/image_cropper.dart'; @@ -10,6 +11,7 @@ import 'package:talawa/locator.dart'; /// Services include: /// * `cropImage` /// * `convertToBase64` +/// * `decodeBase64` class ImageService { /// Global instance of ImageCropper. final ImageCropper _imageCropper = locator(); @@ -71,10 +73,93 @@ class ImageService { Future convertToBase64(File file) async { try { final List bytes = await file.readAsBytes(); - final String base64String = base64Encode(bytes); - return base64String; + + // Check the first few bytes (magic numbers) to identify the file type + final String? mimeType = _getMimeType(bytes); + + // If mimeType is null, fallback to generic base64 encoding + if (mimeType != null) { + final String base64String = base64Encode(bytes); + return 'data:$mimeType;base64,$base64String'; + } else { + return base64Encode(bytes); + } } catch (error) { return ''; } } + + /// Decodes a base64 string back into an image File. + /// + /// **params**: + /// * `base64String`: The base64 encoded string. + /// + /// **returns**: + /// * `Uint8List?`: Decoded image file, null if an error occurs. + Uint8List? decodeBase64(String base64String) { + try { + // Remove the prefix "data:image/...;base64," if it exists + final regex = RegExp('data:image/[^;]+;base64,'); + final cleanedBase64 = base64String.replaceFirst(regex, ''); + final Uint8List bytes = base64Decode(cleanedBase64); + + return bytes; + } catch (error) { + print("Error decoding base64: $error"); + return null; + } + } + + /// method to get what format image is. + /// + /// **params**: + /// * `bytes`: bytes data of the image + /// + /// **returns**: + /// * `String?`: define_the_return + String? _getMimeType(List bytes) { + if (bytes.length < 4) { + return null; + } + + // JPEG: starts with 0xFF, 0xD8, and ends with 0xFF, 0xD9 + if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'image/jpeg'; + } + + // PNG: starts with 0x89, 'P', 'N', 'G' + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + + // GIF: starts with 'G', 'I', 'F' + if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { + return 'image/gif'; + } + + // BMP: starts with 'B', 'M' + if (bytes[0] == 0x42 && bytes[1] == 0x4D) { + return 'image/bmp'; + } + + // WebP: starts with 'R', 'I', 'F', 'F' followed by 'W', 'E', 'B', 'P' + if (bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + if (bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'image/webp'; + } + } + + // Add more file type checks here if needed (e.g., TIFF, HEIC, etc.) + + return null; + } } diff --git a/lib/utils/fund_queries.dart b/lib/utils/fund_queries.dart new file mode 100644 index 000000000..5958e4f57 --- /dev/null +++ b/lib/utils/fund_queries.dart @@ -0,0 +1,249 @@ +/// This class contains the required mutations and queries for funds. +class FundQueries { + /// Fetches funds by organization ID. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch funds associated with the specified organization ID. + String fetchOrgFunds() { + return ''' + query FundsByOrganization( + \$orgId: ID! + \$filter: String + \$orderBy: FundOrderByInput + ) { + fundsByOrganization( + organizationId: \$orgId + where: { name_contains: \$filter } + orderBy: \$orderBy + ) { + _id + name + refrenceNumber + taxDeductible + isDefault + isArchived + createdAt + organizationId + creator { + _id + firstName + lastName + } + } + } + '''; + } + + /// Fetches campaigns by fund ID. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch campaigns associated with the specified fund ID. + String fetchCampaignsByFund() { + return ''' + query GetFundById( + \$fundId: ID! + \$where: CampaignWhereInput + \$orderBy: CampaignOrderByInput + ) { + getFundById(id: \$fundId, where: \$where, orderBy: \$orderBy) { + campaigns { + _id + endDate + fundingGoal + name + startDate + currency + } + } + } + '''; + } + + /// Fetches pledges by campaign ID. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch pledges associated with the specified campaign ID. + String fetchPledgesByCampaign() { + return ''' + query GetFundraisingCampaigns( + \$where: CampaignWhereInput + \$pledgeOrderBy: PledgeOrderByInput + ) { + getFundraisingCampaigns(where: \$where, pledgeOrderBy: \$pledgeOrderBy) { + name + fundingGoal + currency + startDate + endDate + pledges { + _id + amount + currency + endDate + startDate + users { + _id + firstName + lastName + image + } + } + } + } + '''; + } + + /// Fetches user campaigns. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch campaigns associated with the user. + String fetchUserCampaigns() { + return ''' + query GetFundraisingCampaigns( + \$where: CampaignWhereInput + \$campaignOrderBy: CampaignOrderByInput + ) { + getFundraisingCampaigns(where: \$where, campaignOrderby: \$campaignOrderBy) { + _id + startDate + endDate + name + fundingGoal + currency + } + } + '''; + } + + /// Fetches pledges by user ID. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL query string to fetch pledges associated with the specified user ID. + String fetchUserPledges() { + return ''' + query GetPledgesByUserId( + \$userId: ID! + \$where: PledgeWhereInput + \$orderBy: PledgeOrderByInput + ) { + getPledgesByUserId(userId: \$userId, where: \$where, orderBy: \$orderBy) { + _id + amount + startDate + endDate + campaign { + _id + name + endDate + } + currency + users { + _id + firstName + lastName + image + } + } + } + '''; + } + + /// Mutation to create a pledge. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to create a pledge. + String createPledge() { + return ''' + mutation CreateFundraisingCampaignPledge( + \$campaignId: ID! + \$amount: Float! + \$currency: Currency! + \$startDate: Date! + \$endDate: Date! + \$userIds: [ID!]! + ) { + createFundraisingCampaignPledge( + data: { + campaignId: \$campaignId + amount: \$amount + currency: \$currency + startDate: \$startDate + endDate: \$endDate + userIds: \$userIds + } + ) { + _id + } + } + '''; + } + + /// Mutation to update a pledge. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to update a pledge. + String updatePledge() { + return ''' + mutation UpdateFundraisingCampaignPledge( + \$id: ID! + \$amount: Float + \$currency: Currency + \$startDate: Date + \$endDate: Date + \$users: [ID!] + ) { + updateFundraisingCampaignPledge( + id: \$id + data: { + users: \$users + amount: \$amount + currency: \$currency + startDate: \$startDate + endDate: \$endDate + } + ) { + _id + + } + } + '''; + } + + /// Mutation to delete a pledge. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: Returns a GraphQL mutation string to delete a pledge. + String deletePledge() { + return ''' + mutation DeleteFundraisingCampaignPledge(\$id: ID!) { + removeFundraisingCampaignPledge(id: \$id) { + _id + } + } + '''; + } +} diff --git a/lib/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart b/lib/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart new file mode 100644 index 000000000..f36966599 --- /dev/null +++ b/lib/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart @@ -0,0 +1,432 @@ +import 'dart:async'; + +import 'package:currency_picker/currency_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:talawa/constants/constants.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/services/fund_service.dart'; +import 'package:talawa/view_model/base_view_model.dart'; + +/// FundViewModel class helps to interact with model to serve data to view for fund management section. +/// +/// Methods include: +/// * `initialise` : to initialize the view model and fetch initial data. +/// * `fetchFunds` : to fetch all funds in the organization. +/// * `fetchCampaigns` : to fetch campaigns for a specific fund. +/// * `fetchPledges` : to fetch pledges for a specific campaign. +/// * `createPledge` : to create a new pledge. +/// * `updatePledge` : to update an existing pledge. +/// * `deletePledge` : to delete a pledge. +/// * `selectFund` : to select a fund and fetch its campaigns. +/// * `selectCampaign` : to select a campaign and fetch its pledges. +class FundViewModel extends BaseModel { + // Importing services. + final FundService _fundService = locator(); + + // Local caching variables for a session. + List _funds = []; + List _filteredFunds = []; + List _campaigns = []; + List _filteredCampaigns = []; + List _allPledges = []; + List _userPledges = []; + List _filteredPledges = []; + + bool _isFetchingFunds = false; + bool _isFetchingCampaigns = false; + bool _isFetchingPledges = false; + + String _fundSortOption = 'createdAt_DESC'; + String _campaignSortOption = 'endDate_DESC'; + String _pledgeSortOption = 'endDate_DESC'; + + String _fundSearchQuery = ''; + String _campaignSearchQuery = ''; + String _pledgerSearchQuery = ''; + + /// used to identify the current fund id. + late String parentFundId; + + /// used to identify the current campaign id. + late String parentcampaignId; + + // Getters + /// getter for the funds. + List get funds => _funds; + + /// List of organization members. + late List orgMembersList = []; + + /// getter for the filtered funds. + List get filteredFunds => _filteredFunds; + + /// getter for the campaigns. + List get campaigns => _campaigns; + + /// getter for the filtered campaigns. + List get filteredCampaigns => _filteredCampaigns; + + /// getter for the all pledges. + List get allPledges => _allPledges; + + /// getter for the user pledges. + List get userPledges => _userPledges; + + /// getter for the filtered Pledges. + List get filteredPledges => _filteredPledges; + + /// getter for isFetchingFunds to show loading indicator. + bool get isFetchingFunds => _isFetchingFunds; + + /// getter for isFetchingCampaigns to show loading indicator. + bool get isFetchingCampaigns => _isFetchingCampaigns; + + /// getter for isFetchingPledges to show loading indicator. + bool get isFetchingPledges => _isFetchingPledges; + + /// getter for funds sorting option. + String get fundSortOption => _fundSortOption; + + /// getter for campaigns sorting option. + String get campaignSortOption => _campaignSortOption; + + /// getter for pldeges sorting option. + String get pledgeSortOption => _pledgeSortOption; + + /// donationCurrency. + String donationCurrency = "USD"; + + /// Currency Symbol. + String donationCurrencySymbol = "\$"; + + /// To initialize the view model. + /// + /// This method sets up the initial state and triggers the fetch of funds. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void initialise() { + _isFetchingFunds = true; + _isFetchingCampaigns = true; + _isFetchingPledges = true; + notifyListeners(); + getCurrentOrgUsersList(); + fetchFunds(); + // Note: We'll fetch campaigns and pledges after selecting a specific fund and campaign + } + + /// This function fetches all funds in the organization. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + Future fetchFunds() async { + _isFetchingFunds = true; + notifyListeners(); + + try { + _funds = await _fundService.getFunds(orderBy: _fundSortOption); + _applyFundFilters(); + _isFetchingFunds = false; + notifyListeners(); + } catch (e) { + print('Error fetching funds: $e'); + _isFetchingFunds = false; + notifyListeners(); + } + } + + /// This function fetches campaigns for a specific fund. + /// + /// **params**: + /// * `fundId`: id of the fund + /// + /// **returns**: + /// None + Future fetchCampaigns(String fundId) async { + _isFetchingCampaigns = true; + parentFundId = fundId; + notifyListeners(); + + try { + _campaigns = + await _fundService.getCampaigns(fundId, orderBy: _campaignSortOption); + _applyCampaignFilters(); + _isFetchingCampaigns = false; + notifyListeners(); + } catch (e) { + print('Error fetching campaigns: $e'); + _isFetchingCampaigns = false; + notifyListeners(); + } + } + + /// methoud to sort funds. + /// + /// **params**: + /// * `option`: sorting option to sort. + /// + /// **returns**: + /// None + void sortFunds(String option) { + if (option != _fundSortOption) { + _fundSortOption = option; + fetchFunds(); + } + } + + /// methoud to sort Campaigns. + /// + /// **params**: + /// * `option`: sorting option to sort. + /// + /// **returns**: + /// None + void sortCampaigns(String option) { + if (option != _campaignSortOption) { + _campaignSortOption = option; + if (_campaigns.isNotEmpty) { + fetchCampaigns(parentFundId); + } + } + } + + /// methoud to sort Pledges. + /// + /// **params**: + /// * `option`: sorting option to sort. + /// + /// **returns**: + /// None + void sortPledges(String option) { + if (option != _pledgeSortOption) { + _pledgeSortOption = option; + if (_userPledges.isNotEmpty) { + fetchPledges(parentcampaignId); + } + } + } + + /// methoud to search Funds. + /// + /// **params**: + /// * `query`: query to search for Funds. + /// + /// **returns**: + /// None + void searchFunds(String query) { + _fundSearchQuery = query.toLowerCase(); + _applyFundFilters(); + notifyListeners(); + } + + /// methoud to search Campaigns. + /// + /// **params**: + /// * `query`: query to search for Funds. + /// + /// **returns**: + /// None + void searchCampaigns(String query) { + _campaignSearchQuery = query.toLowerCase(); + _applyCampaignFilters(); + notifyListeners(); + } + + /// Method to search pledges by pledger. + /// + /// **params**: + /// * `query`: query to search for pledger. + /// + /// **returns**: + /// None + void searchPledgers(String query) { + _pledgerSearchQuery = query.toLowerCase(); + _filteredPledges = _userPledges.where((pledge) { + return pledge.pledgers!.any( + (user) => user.firstName!.toLowerCase().contains(_pledgerSearchQuery), + ); + }).toList(); + notifyListeners(); + } + + /// methoud to apply the filtering logic for funds. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void _applyFundFilters() { + _filteredFunds = _funds.where((fund) { + return fund.name!.toLowerCase().contains(_fundSearchQuery); + }).toList(); + } + + /// methoud to apply the filtering logic for Campaigns. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void _applyCampaignFilters() { + _filteredCampaigns = _campaigns.where((campaign) { + return campaign.name!.toLowerCase().contains(_campaignSearchQuery); + }).toList(); + } + + /// This function fetches pledges for a specific campaign. + /// + /// **params**: + /// * `campaignId`: id of the campaign + /// + /// **returns**: + /// None + Future fetchPledges(String campaignId) async { + _isFetchingPledges = true; + parentcampaignId = campaignId; + notifyListeners(); + try { + _allPledges = await _fundService.getPledgesByCampaign( + campaignId, + orderBy: _pledgeSortOption, + ); + _userPledges = _allPledges + .where( + (pledge) => pledge.pledgers! + .any((pledger) => pledger.id == userConfig.currentUser.id), + ) + .toList(); + _filteredPledges = List.from(_userPledges); + _isFetchingPledges = false; + notifyListeners(); + } catch (e) { + _isFetchingPledges = false; + notifyListeners(); + } + } + + /// This method changes the currency of the user for donation purpose. + /// + /// **params**: + /// * `context`: BuildContext of the widget + /// * `setter`: Setter Function + /// + /// **returns**: + /// None + void changeCurrency( + BuildContext context, + void Function(void Function()) setter, + ) { + showCurrencyPicker( + context: context, + currencyFilter: supportedCurrencies, + onSelect: (Currency currency) { + setter(() { + donationCurrency = currency.code; + donationCurrencySymbol = currency.symbol; + }); + }, + ); + } + + /// Method to get list of all members in the org. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + Future getCurrentOrgUsersList() async { + orgMembersList = + await organizationService.getOrgMembersList(userConfig.currentOrg.id!); + } + + /// This function creates a new pledge. + /// + /// **params**: + /// * `pledgeData`: Map containing the data for the new pledge + /// + /// **returns**: + /// None + Future createPledge(Map pledgeData) async { + try { + await _fundService.createPledge(pledgeData); + await fetchPledges(parentcampaignId); + } catch (e) { + print('Error creating pledge: $e'); + } + } + + /// This function updates an existing pledge. + /// + /// **params**: + /// * `updatedPledgeData`: Map containing the updated data for the pledge + /// + /// **returns**: + /// None + Future updatePledge(Map updatedPledgeData) async { + try { + await _fundService.updatePledge(updatedPledgeData); + await fetchPledges(parentcampaignId); + } catch (e) { + print('Error updating pledge: $e'); + } + } + + /// This function deletes a pledge. + /// + /// **params**: + /// * `pledgeId`: id of the pledge to be deleted + /// * `campaignId`: id of the campaign the pledge belongs to + /// + /// **returns**: + /// None + Future deletePledge(String pledgeId, String campaignId) async { + try { + await _fundService.deletePledge(pledgeId); + // Refresh pledges after deleting + await fetchPledges(campaignId); + } catch (e) { + print('Error updating pledge: $e'); + } + } + + /// This function selects a fund and fetches its campaigns. + /// + /// **params**: + /// * `fundId`: id of the selected fund + /// + /// **returns**: + /// None + void selectFund(String fundId) { + fetchCampaigns(fundId); + } + + /// This function selects a campaign and fetches its pledges. + /// + /// **params**: + /// * `campaignId`: id of the selected campaign + /// + /// **returns**: + /// None + void selectCampaign(String campaignId) { + fetchPledges(campaignId); + } + + @override + void dispose() { + // Clean up any resources if needed + super.dispose(); + } +} diff --git a/lib/view_model/main_screen_view_model.dart b/lib/view_model/main_screen_view_model.dart index 44830316f..60262fac6 100644 --- a/lib/view_model/main_screen_view_model.dart +++ b/lib/view_model/main_screen_view_model.dart @@ -12,7 +12,7 @@ import 'package:talawa/view_model/base_view_model.dart'; // import 'package:talawa/views/after_auth_screens/chat/chat_list_screen.dart'; import 'package:talawa/views/after_auth_screens/events/explore_events.dart'; import 'package:talawa/views/after_auth_screens/feed/organization_feed.dart'; -import 'package:talawa/views/after_auth_screens/profile/profile_page.dart'; +import 'package:talawa/views/after_auth_screens/funds/funds_screen.dart'; import 'package:talawa/views/demo_screens/explore_events_demo.dart'; import 'package:talawa/views/demo_screens/organization_feed_demo.dart'; import 'package:talawa/views/demo_screens/profile_page_demo.dart'; @@ -260,10 +260,10 @@ class MainScreenViewModel extends BaseModel { // ), BottomNavigationBarItem( icon: Icon( - Icons.account_circle, + Icons.attach_money_sharp, key: keyBNProfile, ), - label: AppLocalizations.of(context)!.strictTranslate('Profile'), + label: AppLocalizations.of(context)!.strictTranslate('Funds'), ), ]; @@ -284,9 +284,8 @@ class MainScreenViewModel extends BaseModel { // const ChatPage( // key: Key('Chats'), // ), - ProfilePage( - key: keySPEditProfile, - homeModel: this, + const FundScreen( + key: Key('FundScreen'), ), ]; pluginList = 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 index 10cbc8883..bcefce756 100644 --- a/lib/views/after_auth_screens/events/create_agenda_item_page.dart +++ b/lib/views/after_auth_screens/events/create_agenda_item_page.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -426,7 +425,12 @@ class _CreateAgendaItemPageState extends State { itemCount: attachments.length, itemBuilder: (context, index) { final base64String = attachments[index]; - final imageData = base64Decode(base64String); + final imageData = imageService.decodeBase64(base64String); + if (imageData == null) { + return const Center( + child: Icon(Icons.broken_image, color: Colors.red), + ); + } return Stack( children: [ ClipRRect( 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 index f620c3c63..ca03383f2 100644 --- a/lib/views/after_auth_screens/events/edit_agenda_item_page.dart +++ b/lib/views/after_auth_screens/events/edit_agenda_item_page.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:delightful_toast/delight_toast.dart'; import 'package:delightful_toast/toast/components/toast_card.dart'; import 'package:flutter/material.dart'; @@ -325,13 +323,14 @@ class _EditAgendaItemPageState extends State { itemCount: model.attachments.length, itemBuilder: (context, index) { final base64String = model.attachments[index]; - final imageData = base64Decode(base64String); + final imageData = + imageService.decodeBase64(base64String); return Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( - imageData, + imageData!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, 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 index 0c27135b0..f7efad278 100644 --- a/lib/views/after_auth_screens/events/manage_agenda_items_screen.dart +++ b/lib/views/after_auth_screens/events/manage_agenda_items_screen.dart @@ -60,6 +60,7 @@ class ManageAgendaScreen extends StatelessWidget { }, onDelete: () async { await model.deleteAgendaItem(item.id!); + await model.fetchAgendaItems(); if (context.mounted) { DelightToastBar( autoDismiss: true, diff --git a/lib/views/after_auth_screens/feed/organization_feed.dart b/lib/views/after_auth_screens/feed/organization_feed.dart index 5913f121e..e12ee26a0 100644 --- a/lib/views/after_auth_screens/feed/organization_feed.dart +++ b/lib/views/after_auth_screens/feed/organization_feed.dart @@ -4,6 +4,7 @@ import 'package:talawa/services/size_config.dart'; import 'package:talawa/utils/app_localization.dart'; import 'package:talawa/view_model/after_auth_view_models/feed_view_models/organization_feed_view_model.dart'; import 'package:talawa/view_model/main_screen_view_model.dart'; +import 'package:talawa/views/after_auth_screens/profile/profile_page.dart'; import 'package:talawa/views/base_view.dart'; import 'package:talawa/widgets/pinned_post.dart'; import 'package:talawa/widgets/post_list_widget.dart'; @@ -82,6 +83,36 @@ class _OrganizationFeedState extends State { MainScreenViewModel.scaffoldKey.currentState!.openDrawer(); }, ), + actions: [ + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfilePage( + key: const Key('UserProfile'), + homeModel: widget.homeModel, + ), + ), + ); + }, + icon: CircleAvatar( + radius: 16, + backgroundImage: userConfig.currentUser.image != null + ? NetworkImage(userConfig.currentUser.image!) + : null, + backgroundColor: userConfig.currentUser.image == null + ? Colors.blue + : Colors.transparent, + child: userConfig.currentUser.image == null + ? const Icon( + Icons.person_3, + size: 26, + ) + : null, + ), + ), + ], ), // if the model is fetching the data then renders Circular Progress Indicator else renders the result. body: model.isFetchingPosts || model.isBusy diff --git a/lib/views/after_auth_screens/funds/fund_pledges_screen.dart b/lib/views/after_auth_screens/funds/fund_pledges_screen.dart new file mode 100644 index 000000000..d7985f866 --- /dev/null +++ b/lib/views/after_auth_screens/funds/fund_pledges_screen.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/views/base_view.dart'; +import 'package:talawa/widgets/add_pledge_dialogue_box.dart'; +import 'package:talawa/widgets/pledge_card.dart'; +import 'package:talawa/widgets/update_pledge_dialogue_box.dart'; + +/// Screen to show the pledges associated to the campaign. +class PledgesScreen extends StatefulWidget { + const PledgesScreen({ + super.key, + required this.campaign, + }); + + /// Instance of the parent campaign. + final Campaign campaign; + + @override + _PledgesScreenState createState() => _PledgesScreenState(); +} + +class _PledgesScreenState extends State { + bool _showPledged = true; + + @override + Widget build(BuildContext context) { + return BaseView( + onModelReady: (model) { + model.fetchPledges(widget.campaign.id!); + model.getCurrentOrgUsersList(); + }, + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + 'Pledges for ${widget.campaign.name}', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + color: Colors.white, + ), + ), + backgroundColor: Colors.green, + ), + body: Column( + children: [ + _buildTabButtons(), + _buildProgressIndicator(model), + _buildSearchAndSortBar(model), + Expanded( + child: _buildPledgesList(model), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddPledgeDialog(context, model), + backgroundColor: Colors.green, + child: const Icon(Icons.add), + ), + ); + }, + ); + } + + /// Builds the tab buttons to toggle between "Pledged" and "Raised". + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `Widget`: the widget displaying tab buttons for toggling views. + Widget _buildTabButtons() { + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildTabButton('Pledged', _showPledged), + _buildTabButton('Raised', !_showPledged), + ], + ), + ); + } + + /// Creates an individual tab button with styling. + /// + /// **params**: + /// * `label`: the text label displayed on the button. + /// * `isSelected`: whether the button is currently selected. + /// + /// **returns**: + /// * `Widget`: a styled button for toggling views. + Widget _buildTabButton(String label, bool isSelected) { + return Container( + height: 40, + width: 120, + decoration: BoxDecoration( + color: isSelected ? Colors.green : Colors.grey, + borderRadius: BorderRadius.horizontal( + left: label == 'Pledged' ? const Radius.circular(8.0) : Radius.zero, + right: label == 'Raised' ? const Radius.circular(8.0) : Radius.zero, + ), + ), + child: TextButton( + onPressed: () { + setState(() { + _showPledged = label == 'Pledged'; + }); + }, + child: Text( + label, + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + + /// Displays the progress indicator bar showing the campaign's funding status. + /// + /// **params**: + /// * `model`: the data model containing pledge and funding data. + /// + /// **returns**: + /// * `Widget`: a progress bar widget showing the funding progress. + /// Displays the progress indicator bar showing the campaign's funding status. + /// + /// **params**: + /// * `model`: the data model containing pledge and funding data. + /// + /// **returns**: + /// * `Widget`: a custom progress bar widget showing the funding progress. + Widget _buildProgressIndicator(FundViewModel model) { + final totalPledged = model.allPledges.fold(0, (int sum, pledge) { + final amount = pledge.amount ?? 0; + return sum + amount; + }); + + final goalAmount = widget.campaign.fundingGoal!; + final totalRaised = model.allPledges.fold(0, (int sum, pledge) { + const amountDonated = 0; + return sum + amountDonated; + }); + + final double progress = + _showPledged ? (totalPledged / goalAmount) : (totalRaised / goalAmount); + final double progressValue = progress > 1.0 ? 1.0 : progress; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Stack( + children: [ + Container( + height: 20, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ), + Container( + height: 20, + width: progressValue * MediaQuery.of(context).size.width, + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + ), + Positioned.fill( + child: Center( + child: Text( + '${(progressValue * 100).toStringAsFixed(1)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _showPledged + ? 'Pledged: \$${totalPledged.toStringAsFixed(2)}' + : 'Raised: \$${totalRaised.toStringAsFixed(2)}', + ), + Text('Goal: \$${goalAmount.toStringAsFixed(2)}'), + ], + ), + ], + ), + ); + } + + /// Builds the search and sort bar above the pledges list. + /// + /// **params**: + /// * `model`: the data model for handling search and sorting actions. + /// + /// **returns**: + /// * `Widget`: a widget with search and sorting functionalities. + Widget _buildSearchAndSortBar(FundViewModel model) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + flex: 3, + child: TextField( + onChanged: (value) => model.searchPledgers(value), + decoration: InputDecoration( + hintText: 'Search by Pledgers', + prefixIcon: const Icon(Icons.search, color: Colors.green), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: const BorderSide(color: Colors.green), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: const BorderSide(color: Colors.green, width: 2), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.green), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + borderRadius: BorderRadius.circular(20), + dropdownColor: const Color.fromARGB(255, 49, 49, 49), + isExpanded: true, + value: model.pledgeSortOption, + icon: const Icon(Icons.sort, color: Colors.green), + items: const [ + DropdownMenuItem( + value: 'endDate_DESC', + child: Text( + 'End Date (Latest)', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + ), + ), + ), + DropdownMenuItem( + value: 'endDate_ASC', + child: Text( + 'End Date (Earliest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + DropdownMenuItem( + value: 'amount_DESC', + child: Text( + 'Amount (Highest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + DropdownMenuItem( + value: 'amount_ASC', + child: Text( + 'Amount (Lowest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + ], + onChanged: (String? newValue) { + if (newValue != null) { + model.sortPledges(newValue); + } + }, + ), + ), + ), + ), + ], + ), + ); + } + + /// Builds the list of pledges as a ListView. + /// + /// **params**: + /// * `model`: the data model containing pledge details. + /// + /// **returns**: + /// * `Widget`: a list view of all pledges. + Widget _buildPledgesList(FundViewModel model) { + if (model.isFetchingPledges) { + return const Center(child: CircularProgressIndicator()); + } + if (model.allPledges.isEmpty) { + return const Center( + child: Text( + 'No pledges found.', + style: TextStyle(fontSize: 18), + ), + ); + } + if (model.userPledges.isEmpty) { + return const Center( + child: Text( + 'There are no pledges you are part of', + style: TextStyle(fontSize: 18), + ), + ); + } + return ListView.builder( + itemCount: model.filteredPledges.length, + itemBuilder: (context, index) { + final pledge = model.filteredPledges[index]; + return PledgeCard( + pledge: pledge, + onUpdate: () => _showUpdatePledgeDialog(context, model, pledge), + onDelete: () => _showDeleteConfirmationDialog(context, model, pledge), + ); + }, + ); + } + + /// Shows the dialog for adding a new pledge. + /// + /// **params**: + /// * `context`: the build context. + /// * `model`: the data model to add pledge data. + /// + /// **returns**: + /// None + void _showAddPledgeDialog(BuildContext context, FundViewModel model) { + showDialog( + context: context, + builder: (context) => AddPledgeDialog( + campaignId: widget.campaign.id!, + model: model, + onSubmit: (pledgeData) { + model.createPledge(pledgeData); + }, + ), + ); + } + + /// Shows the dialog to update an existing pledge. + /// + /// **params**: + /// * `context`: the build context. + /// * `model`: the data model to add pledge data. + /// * `pledge`: the pledge being updated. + /// + /// **returns**: + /// None + void _showUpdatePledgeDialog( + BuildContext context, + FundViewModel model, + Pledge pledge, + ) { + showDialog( + context: context, + builder: (BuildContext context) => UpdatePledgeDialog( + model: model, + pledge: pledge, + onSubmit: (updatedPledgeData) { + model.updatePledge(updatedPledgeData); + Navigator.of(context).pop(); + }, + ), + ); + } + + /// Show the confirmation dialogue to delete the pldege. + /// + /// **params**: + /// * `context`: the build context. + /// * `model`: the data model to add pledge data. + /// * `pledge`: the pledge being deleted. + /// + /// **returns**: + /// None + void _showDeleteConfirmationDialog( + BuildContext context, + FundViewModel model, + Pledge pledge, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Pledge'), + content: const Text('Are you sure you want to delete this pledge?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + model.deletePledge(pledge.id!, widget.campaign.id!); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} diff --git a/lib/views/after_auth_screens/funds/fundraising_campaigns_screen.dart b/lib/views/after_auth_screens/funds/fundraising_campaigns_screen.dart new file mode 100644 index 000000000..be1ff9190 --- /dev/null +++ b/lib/views/after_auth_screens/funds/fundraising_campaigns_screen.dart @@ -0,0 +1,455 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/views/after_auth_screens/funds/fund_pledges_screen.dart'; +import 'package:talawa/views/base_view.dart'; + +/// The CampaignsScreen widget displays a list of campaigns associated with a specific fund. +class CampaignsScreen extends StatefulWidget { + const CampaignsScreen({ + super.key, + required this.fundId, + required this.fundName, + }); + + /// The unique identifier for the fund whose campaigns are to be displayed. + final String fundId; + + /// The name of the fund associated with the campaigns. + final String fundName; + + @override + State createState() => _CampaignsScreenState(); +} + +class _CampaignsScreenState extends State { + @override + Widget build(BuildContext context) { + return BaseView( + onModelReady: (model) => model.fetchCampaigns(widget.fundId), + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + AppLocalizations.of(context)!.strictTranslate('Campaigns'), + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + color: Colors.white, + ), + ), + backgroundColor: Colors.green, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + flex: 3, + child: TextField( + onChanged: (value) => model.searchCampaigns(value), + decoration: InputDecoration( + hintText: 'Search Campaigns', + prefixIcon: + const Icon(Icons.search, color: Colors.green), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: const BorderSide(color: Colors.green), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: + const BorderSide(color: Colors.green, width: 2), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.green), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + borderRadius: BorderRadius.circular(20), + dropdownColor: + const Color.fromARGB(255, 49, 49, 49), + isExpanded: true, + value: model.campaignSortOption, + icon: const Icon(Icons.sort, color: Colors.green), + items: const [ + DropdownMenuItem( + value: 'endDate_DESC', + child: Text( + 'End Date (Latest)', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + ), + ), + ), + DropdownMenuItem( + value: 'endDate_ASC', + child: Text( + 'End Date (Earliest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + DropdownMenuItem( + value: 'amount_DESC', + child: Text( + 'Amount (Highest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + DropdownMenuItem( + value: 'amount_ASC', + child: Text( + 'Amount (Lowest)', + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14), + ), + ), + ], + onChanged: (String? newValue) { + if (newValue != null) { + model.sortCampaigns(newValue); + } + }, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: model.isFetchingCampaigns + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: () async => + model.fetchCampaigns(widget.fundId), + child: model.filteredCampaigns.isEmpty + ? const Center( + child: Text('No campaigns for this fund.'), + ) + : ListView.builder( + itemCount: model.filteredCampaigns.length, + itemBuilder: (context, index) { + final campaign = + model.filteredCampaigns[index]; + return CampaignCard(campaign: campaign); + }, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +/// The CampaignCard widget displays details of a specific campaign. +class CampaignCard extends StatelessWidget { + const CampaignCard({super.key, required this.campaign}); + + /// The campaign data to display within this card. + final Campaign campaign; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const raisedAmount = 0.0; + final goalAmount = campaign.fundingGoal ?? 0; + final progress = goalAmount > 0 ? raisedAmount / goalAmount : 0.0; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(theme), + _buildBody(theme, raisedAmount, goalAmount, progress, context), + _buildFooter(context, theme), + ], + ), + ); + } + + /// Builds the header section of the campaign card. + /// + /// **params**: + /// * `theme`: The current [ThemeData] for styling purposes. + /// + /// **returns**: + /// * `Widget`: The constructed header widget containing campaign title and icon. + Widget _buildHeader(ThemeData theme) { + return Container( + decoration: const BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.campaign, color: Colors.white, size: 30), + const SizedBox(width: 12), + Expanded( + child: Text( + campaign.name ?? 'Unnamed Campaign', + style: theme.textTheme.headlineSmall!.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + /// Builds the main body section of the campaign card, displaying progress and campaign details. + /// + /// **params**: + /// * `theme`: The current [ThemeData] for styling purposes. + /// * `raisedAmount`: The amount raised for the campaign. + /// * `goalAmount`: The funding goal for the campaign. + /// * `progress`: The percentage of the funding goal reached. + /// * `context`: The [BuildContext] in which this widget is built. + /// + /// **returns**: + /// * `Widget`: The constructed body widget containing campaign progress and details. + Widget _buildBody( + ThemeData theme, + double raisedAmount, + double goalAmount, + double progress, + BuildContext context, + ) { + return Container( + decoration: const BoxDecoration( + color: Color.fromARGB(255, 37, 37, 37), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProgressIndicator( + theme, + raisedAmount, + goalAmount, + progress, + context, + ), + const SizedBox(height: 16), + _buildInfoGrid(theme), + ], + ), + ); + } + + /// Constructs a progress indicator to show the campaign's funding progress. + /// + /// **params**: + /// * `theme`: The current [ThemeData] for styling purposes. + /// * `raisedAmount`: The amount raised for the campaign. + /// * `goalAmount`: The funding goal for the campaign. + /// * `progress`: The percentage of the funding goal reached. + /// * `context`: The [BuildContext] in which this widget is built. + /// + /// **returns**: + /// * `Widget`: The progress indicator widget showing the campaign's funding status. + Widget _buildProgressIndicator( + ThemeData theme, + double raisedAmount, + double goalAmount, + double progress, + BuildContext context, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + height: 20, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ), + Container( + height: 20, + width: progress * MediaQuery.of(context).size.width, + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + ), + Positioned.fill( + child: Center( + child: Text( + '${(progress * 100).toStringAsFixed(1)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${campaign.currency ?? ''} ${raisedAmount.toStringAsFixed(2)} raised', + style: theme.textTheme.bodyLarge! + .copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'Goal: ${campaign.currency ?? ''} ${goalAmount.toStringAsFixed(2)}', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ], + ); + } + + /// Builds a widget with a specific theme, adding any necessary end punctuation. + /// + /// **params**: + /// * `theme`: The [ThemeData] object, providing styling and theme-specific properties. + /// + /// **returns**: + /// * `Widget`: The constructed widget styled according to the provided theme. + Widget _buildInfoGrid(ThemeData theme) { + return GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 2.5, + children: [ + _buildInfoItem(theme, 'Start Date', _formatDate(campaign.startDate)), + _buildInfoItem(theme, 'End Date', _formatDate(campaign.endDate)), + _buildInfoItem(theme, 'Status', _getCampaignStatus()), + ], + ); + } + + /// Builds a widget with a label and value, styled according to the theme, and ending with punctuation. + /// + /// **params**: + /// * `theme`: The [ThemeData] object, providing styling and theme-specific properties. + /// * `label`: A [String] representing the label text displayed in the widget. + /// * `value`: A [String] or [dynamic] type representing the associated value displayed in the widget. + /// + /// **returns**: + /// * `Widget`: The constructed widget containing both the label and value. + Widget _buildInfoItem(ThemeData theme, String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall!.copyWith(color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + value, + style: + theme.textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.bold), + ), + ], + ); + } + + /// Constructs a widget that combines context-specific data with a theme for display. + /// + /// **params**: + /// * `context`: The [BuildContext] in which the widget is built, providing access to the widget tree. + /// * `theme`: The [ThemeData] object, defining styling for the widget. + /// + /// **returns**: + /// * `Widget`: A widget styled with theme data and contextual details. + Widget _buildFooter(BuildContext context, ThemeData theme) { + return Container( + decoration: const BoxDecoration( + color: Color.fromARGB(255, 37, 37, 37), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(15), + bottomRight: Radius.circular(15), + ), + ), + padding: const EdgeInsets.all(16), + child: Center( + child: ElevatedButton.icon( + icon: const Icon(Icons.volunteer_activism), + label: const Text('Pledge'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PledgesScreen(campaign: campaign), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + ); + } + + /// Formats a date as a [String] with an ending punctuation, for consistent display in UI. + /// + /// **params**: + /// * `date`: A [DateTime] object representing the date to be formatted. + /// + /// **returns**: + /// * `String`: The formatted date as a string. + String _formatDate(DateTime? date) { + if (date == null) return 'Not set'; + return DateFormat('MMM d, y').format(date); + } + + /// Retrieves a string representation with end punctuation for display purposes. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `String`: The formatted string with required punctuation. + String _getCampaignStatus() { + final now = DateTime.now(); + if (campaign.startDate == null || campaign.endDate == null) { + return 'Unknown'; + } + if (now.isBefore(campaign.startDate!)) return 'Upcoming'; + if (now.isAfter(campaign.endDate!)) return 'Ended'; + return 'Active'; + } +} diff --git a/lib/views/after_auth_screens/funds/funds_screen.dart b/lib/views/after_auth_screens/funds/funds_screen.dart new file mode 100644 index 000000000..cf2974d1b --- /dev/null +++ b/lib/views/after_auth_screens/funds/funds_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/view_model/main_screen_view_model.dart'; +import 'package:talawa/views/after_auth_screens/funds/fundraising_campaigns_screen.dart'; +import 'package:talawa/views/base_view.dart'; + +/// a_line_ending_with_end_punctuation. +class FundScreen extends StatefulWidget { + const FundScreen({super.key}); + + @override + State createState() => _FundScreenState(); +} + +class _FundScreenState extends State { + @override + Widget build(BuildContext context) { + return BaseView( + onModelReady: (model) => model.initialise(), + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.green, + elevation: 0.0, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.menu, color: Colors.white), + onPressed: () { + MainScreenViewModel.scaffoldKey.currentState!.openDrawer(); + }, + ), + title: Text( + AppLocalizations.of(context)!.strictTranslate('Funds'), + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + color: Colors.white, + ), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + flex: 3, + child: TextField( + onChanged: (value) => model.searchFunds(value), + decoration: InputDecoration( + hintText: 'Search Funds', + prefixIcon: + const Icon(Icons.search, color: Colors.green), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: const BorderSide(color: Colors.green), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: + const BorderSide(color: Colors.green, width: 2), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.green), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + borderRadius: BorderRadius.circular(20), + dropdownColor: + const Color.fromARGB(255, 49, 49, 49), + isExpanded: true, + value: model.fundSortOption, + icon: const Icon(Icons.sort, color: Colors.green), + items: const [ + DropdownMenuItem( + value: 'createdAt_DESC', + child: Text('Newest'), + ), + DropdownMenuItem( + value: 'createdAt_ASC', + child: Text('Oldest'), + ), + ], + onChanged: (String? newValue) { + if (newValue != null) { + model.sortFunds(newValue); + } + }, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: model.isFetchingFunds + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: () async => model.fetchFunds(), + child: model.filteredFunds.isEmpty + ? const Center( + child: Text('No funds in this organization.'), + ) + : ListView.builder( + itemCount: model.filteredFunds.length, + itemBuilder: (context, index) { + final fund = model.filteredFunds[index]; + return FundCard(fund: fund); + }, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +/// a_line_ending_with_end_punctuation. +class FundCard extends StatelessWidget { + const FundCard({super.key, required this.fund}); + + /// a_line_ending_with_end_punctuation. + final Fund fund; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.all(SizeConfig.screenWidth! * 0.03), + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.fromARGB(255, 43, 62, 44), + Color.fromARGB(255, 25, 121, 29), + ], + ), + ), + child: Padding( + padding: EdgeInsets.all(SizeConfig.screenWidth! * 0.04), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + fund.name ?? 'Unnamed Fund', + style: + Theme.of(context).textTheme.headlineSmall!.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + _buildStatusChip(context), + ], + ), + SizedBox(height: SizeConfig.screenHeight! * 0.02), + _buildInfoRow( + context, + Icons.person, + 'Created by', + '${fund.creator!.firstName} ${fund.creator!.lastName}', + ), + SizedBox(height: SizeConfig.screenHeight! * 0.01), + _buildInfoRow( + context, + Icons.calendar_today, + 'Created on', + _formatDate(fund.createdAt), + ), + SizedBox(height: SizeConfig.screenHeight! * 0.02), + Center( + child: ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CampaignsScreen( + fundId: fund.id!, + fundName: fund.name!, + ), + ), + ); + }, + icon: const Icon(Icons.campaign, color: Colors.green), + label: Text( + AppLocalizations.of(context)! + .strictTranslate('View Campaigns'), + style: const TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric( + horizontal: SizeConfig.screenWidth! * 0.05, + vertical: SizeConfig.screenHeight! * 0.015, + ), + backgroundColor: const Color.fromARGB(255, 235, 235, 235), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// a_line_ending_with_end_punctuation. + /// + /// more_info_if_required + /// + /// **params**: + /// * `context`: define_the_param + /// * `icon`: define_the_param + /// * `label`: define_the_param + /// * `value`: define_the_param + /// + /// **returns**: + /// * `Widget`: define_the_return + Widget _buildInfoRow( + BuildContext context, + IconData icon, + String label, + String value, + ) { + return Row( + children: [ + Icon( + icon, + size: SizeConfig.screenHeight! * 0.025, + color: Colors.white70, + ), + SizedBox(width: SizeConfig.screenWidth! * 0.02), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white70, + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + /// a_line_ending_with_end_punctuation. + /// + /// more_info_if_required + /// + /// **params**: + /// * `context`: define_the_param + /// + /// **returns**: + /// * `Widget`: define_the_return + Widget _buildStatusChip(BuildContext context) { + final bool isActive = !(fund.isArchived ?? false); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive + ? const Color.fromARGB(255, 60, 155, 65) + : const Color.fromARGB(255, 144, 59, 59), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isActive ? 'Active' : 'Inactive', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// a_line_ending_with_end_punctuation. + /// + /// more_info_if_required + /// + /// **params**: + /// * `date`: define_the_param + /// + /// **returns**: + /// * `String`: define_the_return + String _formatDate(DateTime? date) { + if (date == null) return 'Unknown'; + return DateFormat('MMM d, yyyy').format(date); + } +} diff --git a/lib/views/after_auth_screens/profile/profile_page.dart b/lib/views/after_auth_screens/profile/profile_page.dart index 742499539..2580e353d 100644 --- a/lib/views/after_auth_screens/profile/profile_page.dart +++ b/lib/views/after_auth_screens/profile/profile_page.dart @@ -36,12 +36,6 @@ class ProfilePage extends StatelessWidget { backgroundColor: Colors.green, elevation: 0.0, centerTitle: true, - leading: IconButton( - color: Colors.white, - icon: const Icon(Icons.menu), - onPressed: () => - MainScreenViewModel.scaffoldKey.currentState!.openDrawer(), - ), key: const Key("ProfilePageAppBar"), title: Text( AppLocalizations.of(context)!.strictTranslate('Profile'), diff --git a/lib/widgets/add_pledge_dialogue_box.dart b/lib/widgets/add_pledge_dialogue_box.dart new file mode 100644 index 000000000..07d4728b0 --- /dev/null +++ b/lib/widgets/add_pledge_dialogue_box.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; + +/// A dialog widget for creating a pledge, allowing the user to input pledge details. +class AddPledgeDialog extends StatefulWidget { + const AddPledgeDialog({ + super.key, + required this.onSubmit, + required this.model, + required this.campaignId, + }); + + /// Callback function that triggers when the form is submitted, passing the pledge data. + final Function(Map) onSubmit; + + /// ViewModel containing organization fund details and related methods. + final FundViewModel model; + + /// Unique identifier for the campaign to which the pledge belongs. + final String campaignId; + + @override + _AddPledgeDialogState createState() => _AddPledgeDialogState(); +} + +class _AddPledgeDialogState extends State { + final _formKey = GlobalKey(); + final TextEditingController _amountController = TextEditingController(); + DateTime? _startDate; + DateTime? _endDate; + final List _selectedPledgers = []; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create Pledge'), + content: SingleChildScrollView( + child: SizedBox( + width: double.maxFinite, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Select Pledger:'), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Wrap( + spacing: 4.0, + children: _selectedPledgers.map((User pledger) { + return Chip( + avatar: CircleAvatar( + backgroundImage: pledger.image != null + ? NetworkImage(pledger.image!) + : null, + backgroundColor: pledger.image == null + ? Colors.blue + : Colors.transparent, + child: pledger.image == null + ? const Icon( + Icons.person_3, + size: 18, + ) + : null, + ), + label: Text( + '${pledger.firstName!} ${pledger.lastName!}', + style: const TextStyle(fontSize: 12), + ), + onDeleted: () { + setState(() { + _selectedPledgers.remove(pledger); + }); + }, + ); + }).toList(), + ), + ), + // PopupMenuButton for user selection + PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: const BorderSide(color: Colors.grey), + ), + icon: const Icon(Icons.add), + onSelected: (User selectedUser) { + setState(() { + _selectedPledgers.add(selectedUser); + }); + }, + itemBuilder: (BuildContext context) { + return widget.model.orgMembersList.where((User user) { + return !_selectedPledgers.contains(user); + }).map((User user) { + return PopupMenuItem( + value: user, + child: _buildPopupMenuItem(user), + ); + }).toList(); + }, + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + children: [ + GestureDetector( + onTap: () { + widget.model.changeCurrency(context, setState); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.model.donationCurrency, + style: const TextStyle(fontSize: 16), + ), + const Icon(Icons.arrow_drop_down_circle_outlined), + ], + ), + ), + ), + Expanded( + child: TextFormField( + controller: _amountController, + decoration: InputDecoration( + labelText: 'Amount', + prefixText: widget.model.donationCurrencySymbol, + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an amount'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() => _startDate = picked); + } + }, + child: _startDate == null + ? const Text('Select Start date') + : Text( + 'Start: ${DateFormat('MMM d, y').format(_startDate!)}', + style: const TextStyle(fontSize: 13), + ), + ), + ), + Expanded( + child: TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: + _endDate ?? (_startDate ?? DateTime.now()), + firstDate: _startDate ?? DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _endDate = picked); + }, + child: _endDate == null + ? const Text('Select End date') + : Text( + 'End: ${DateFormat('MMM d, y').format(_endDate!)}', + style: const TextStyle(fontSize: 13), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate() && + _startDate != null && + _endDate != null && + _selectedPledgers.isNotEmpty) { + widget.onSubmit({ + 'campaignId': widget.campaignId, + 'amount': double.parse(_amountController.text), + 'startDate': DateFormat('yyyy-MM-dd').format(_startDate!), + 'endDate': DateFormat('yyyy-MM-dd').format(_endDate!), + 'userIds': _selectedPledgers.map((user) => user.id).toList(), + 'currency': widget.model.donationCurrency, + }); + Navigator.of(context).pop(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please fill all fields')), + ); + } + }, + child: const Text('Create'), + ), + ], + ); + } + + /// Builds a menu item widget representing a user in the PopupMenuButton. + /// + /// **params**: + /// * `user`: The user whose details are to be displayed in the menu item. + /// + /// **returns**: + /// * `Widget`: A widget displaying the user’s avatar and name. + Widget _buildPopupMenuItem(User user) { + return Column( + children: [ + Row( + children: [ + CircleAvatar( + backgroundImage: + user.image != null ? NetworkImage(user.image!) : null, + backgroundColor: + user.image == null ? Colors.blue : Colors.transparent, + child: user.image == null + ? const Icon( + Icons.person_3, + size: 18, + ) + : null, + ), + const SizedBox(width: 8), + Text('${user.firstName} ${user.lastName}'), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/agenda_item_tile.dart b/lib/widgets/agenda_item_tile.dart index 991d6ac3c..e362e8e6f 100644 --- a/lib/widgets/agenda_item_tile.dart +++ b/lib/widgets/agenda_item_tile.dart @@ -1,6 +1,6 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:talawa/locator.dart'; import 'package:talawa/models/events/event_agenda_item.dart'; /// A widget that displays an expandable agenda item tile. @@ -129,13 +129,15 @@ class ExpandableAgendaItemTile extends StatelessWidget { itemBuilder: (context, index) { final base64String = item.attachments![index]; try { - final imageData = base64Decode(base64String); + final imageData = + imageService.decodeBase64(base64String); return GestureDetector( - onTap: () => _showFullScreenImage(context, imageData), + onTap: () => + _showFullScreenImage(context, imageData!), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( - imageData, + imageData!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, diff --git a/lib/widgets/pledge_card.dart b/lib/widgets/pledge_card.dart new file mode 100644 index 000000000..d167fda49 --- /dev/null +++ b/lib/widgets/pledge_card.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; + +/// a_line_ending_with_end_punctuation. +class PledgeCard extends StatelessWidget { + const PledgeCard({ + super.key, + required this.pledge, + required this.onUpdate, + required this.onDelete, + }); + + /// a_line_ending_with_end_punctuation. + final Pledge pledge; + + /// a_line_ending_with_end_punctuation. + final VoidCallback onUpdate; + + /// a_line_ending_with_end_punctuation. + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color.fromARGB(255, 21, 99, 25), + Colors.green.shade300, + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pledge Group', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.white), + ), + ], + ), + ), + Container( + decoration: const BoxDecoration( + color: Color.fromARGB(255, 36, 36, 36), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pledgers', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 0, + children: [ + ...pledge.pledgers!.take(3).map( + (pledger) => Chip( + avatar: CircleAvatar( + backgroundImage: pledger.image != null + ? NetworkImage(pledger.image!) + : null, + backgroundColor: pledger.image == null + ? Colors.blue + : Colors.transparent, + child: pledger.image == null + ? const Icon( + Icons.person_3, + size: 18, + ) + : null, + ), + label: Text( + '${pledger.firstName!} ${pledger.lastName!}', + style: const TextStyle(fontSize: 12), + ), + ), + ), + if (pledge.pledgers!.length > 3) + Chip( + label: Text( + '+${pledge.pledgers!.length - 3} more', + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pledged', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '\$${pledge.amount!.toStringAsFixed(2)}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.green), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Donated', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '\$0.00', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.blue), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Start Date', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + _formatDate(pledge.startDate), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'End Date', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + _formatDate(pledge.endDate), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + icon: const Icon(Icons.edit), + label: const Text('Update'), + onPressed: onUpdate, + ), + const SizedBox(width: 8), + TextButton.icon( + icon: const Icon(Icons.delete), + label: const Text('Delete'), + onPressed: onDelete, + style: + TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// a_line_ending_with_end_punctuation. + /// + /// more_info_if_required + /// + /// **params**: + /// * `date`: define_the_param + /// + /// **returns**: + /// * `String`: define_the_return + String _formatDate(DateTime? date) { + if (date == null) return 'N/A'; + return DateFormat('MMM d, y').format(date); + } +} diff --git a/lib/widgets/update_pledge_dialogue_box.dart b/lib/widgets/update_pledge_dialogue_box.dart new file mode 100644 index 000000000..90909f5ba --- /dev/null +++ b/lib/widgets/update_pledge_dialogue_box.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; + +/// Displays a dialog to update the pledge details, including amount, dates, and pledgers. +class UpdatePledgeDialog extends StatefulWidget { + const UpdatePledgeDialog({ + super.key, + required this.pledge, + required this.onSubmit, + required this.model, + }); + + /// The pledge object containing current pledge details. + final Pledge pledge; + + /// Callback function to submit updated pledge details. + final Function(Map) onSubmit; + + /// ViewModel object to handle fund-related operations. + final FundViewModel model; + + @override + _UpdatePledgeDialogState createState() => _UpdatePledgeDialogState(); +} + +class _UpdatePledgeDialogState extends State { + final _formKey = GlobalKey(); + late TextEditingController _amountController; + DateTime? _startDate; + DateTime? _endDate; + List _selectedPledgers = []; + + late double _originalAmount; + late String _originalCurrency; + late List _originalPledgers; + DateTime? _originalStartDate; + DateTime? _originalEndDate; + + @override + void initState() { + super.initState(); + _amountController = + TextEditingController(text: widget.pledge.amount?.toString() ?? ''); + _amountController.addListener(_onAmountChanged); + _startDate = widget.pledge.startDate; + _endDate = widget.pledge.endDate; + _selectedPledgers = widget.pledge.pledgers ?? []; + + // Initialize original values + _originalAmount = (widget.pledge.amount ?? 0).toDouble(); + _originalCurrency = widget.model.donationCurrency; + _originalPledgers = List.from(_selectedPledgers); + _originalStartDate = widget.pledge.startDate; + _originalEndDate = widget.pledge.endDate; + } + + /// Changes state if amout is changed. + /// + /// **params**: + /// None + /// + /// **returns**: + /// None + void _onAmountChanged() { + setState(() {}); + } + + @override + void dispose() { + _amountController.removeListener(_onAmountChanged); + _amountController.dispose(); + super.dispose(); + } + + /// Checks if there are any changes in the current pledge details compared to the original values. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `bool`: `true` if any detail has changed, `false` otherwise. + bool _hasChanges() { + double currentAmount = _originalAmount; + if (_amountController.text.isNotEmpty) { + try { + currentAmount = double.tryParse(_amountController.text) ?? 0; + } catch (e) { + // Handle invalid input gracefully if necessary + } + } + + return currentAmount != _originalAmount || + _originalCurrency != widget.model.donationCurrency || + _originalPledgers.length != _selectedPledgers.length || + _selectedPledgers.any((user) => !_originalPledgers.contains(user)) || + _startDate != _originalStartDate || + _endDate != _originalEndDate; + } + + /// Method to get fields that are updated. + /// + /// **params**: + /// None + /// + /// **returns**: + /// * `Map`: Object which include fields which are changed. + Map _getChangedFields() { + final Map changes = {'id': widget.pledge.id}; + try { + final double currentAmount = double.tryParse(_amountController.text) ?? 0; + if (currentAmount != _originalAmount) { + changes['amount'] = currentAmount; + } + } catch (e) { + // Handle parse error if needed + } + if (widget.model.donationCurrency != _originalCurrency) { + changes['currency'] = widget.model.donationCurrency; + } + if (_startDate != _originalStartDate && _startDate != null) { + changes['startDate'] = DateFormat('yyyy-MM-dd').format(_startDate!); + } + if (_endDate != _originalEndDate && _endDate != null) { + changes['endDate'] = DateFormat('yyyy-MM-dd').format(_endDate!); + } + if (_selectedPledgers.length != _originalPledgers.length || + _selectedPledgers.any((user) => !_originalPledgers.contains(user))) { + changes['users'] = _selectedPledgers.map((user) => user.id).toList(); + } + return changes; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Update Pledge'), + content: SingleChildScrollView( + child: SizedBox( + width: double.maxFinite, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Select Pledger:'), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Wrap( + spacing: 2.0, + children: _selectedPledgers.map((User pledger) { + return Chip( + avatar: CircleAvatar( + backgroundImage: pledger.image != null + ? NetworkImage(pledger.image!) + : null, + backgroundColor: pledger.image == null + ? Colors.blue + : Colors.transparent, + child: pledger.image == null + ? const Icon( + Icons.person_3, + size: 18, + ) + : null, + ), + label: Text( + '${pledger.firstName!} ${pledger.lastName!}', + style: const TextStyle(fontSize: 12), + ), + onDeleted: () { + setState(() { + _selectedPledgers.remove(pledger); + }); + }, + ); + }).toList(), + ), + ), + // PopupMenuButton for user selection + PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: const BorderSide(color: Colors.grey), + ), + icon: const Icon(Icons.add), + onSelected: (User selectedUser) { + setState(() { + _selectedPledgers.add(selectedUser); + }); + }, + itemBuilder: (BuildContext context) { + return widget.model.orgMembersList.where((User user) { + return !_selectedPledgers.contains(user); + }).map((User user) { + return PopupMenuItem( + value: user, + child: _buildPopupMenuItem(user), + ); + }).toList(); + }, + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + children: [ + GestureDetector( + onTap: () { + widget.model.changeCurrency(context, setState); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.model.donationCurrency, + style: const TextStyle(fontSize: 16), + ), + const Icon(Icons.arrow_drop_down_circle_outlined), + ], + ), + ), + ), + Expanded( + child: TextFormField( + key: const Key('amount_field'), + controller: _amountController, + decoration: InputDecoration( + labelText: 'Amount', + prefixText: widget.model.donationCurrencySymbol, + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an amount'; + } + final parsedValue = double.tryParse(value); + if (parsedValue == null) { + return 'Amount must be a number'; + } + if (parsedValue <= 0) { + return 'Amount must be greater than zero'; + } + + return null; // Input is valid + }, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _startDate ?? DateTime.now(), + firstDate: _startDate ?? DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() => _startDate = picked); + } + }, + child: _startDate == null + ? const Text('Select Start date') + : Text( + 'Start: ${DateFormat('MMM d, y').format(_startDate!)}', + style: const TextStyle(fontSize: 13), + ), + ), + ), + Expanded( + child: TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: + _endDate ?? (_startDate ?? DateTime.now()), + firstDate: _startDate ?? DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _endDate = picked); + }, + child: _endDate == null + ? const Text('Select End date') + : Text( + 'End: ${DateFormat('MMM d, y').format(_endDate!)}', + style: const TextStyle(fontSize: 13), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + key: const Key('update_btn'), + onPressed: _hasChanges() + ? () { + if (_formKey.currentState!.validate() && + _startDate != null && + _endDate != null && + _selectedPledgers.isNotEmpty) { + widget.onSubmit(_getChangedFields()); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please fill all fields')), + ); + } + } + : null, + child: const Text('Update'), + ), + ], + ); + } + + /// Builds a popup menu item widget displaying the user’s name and image. + /// + /// **params**: + /// * `user`: The user object to display in the popup menu. + /// + /// **returns**: + /// * `Widget`: A widget representing the popup menu item with user's name and avatar. + Widget _buildPopupMenuItem(User user) { + return Column( + children: [ + Row( + children: [ + CircleAvatar( + backgroundImage: + user.image != null ? NetworkImage(user.image!) : null, + backgroundColor: + user.image == null ? Colors.blue : Colors.transparent, + child: user.image == null + ? const Icon( + Icons.person_3, + size: 18, + ) + : null, + ), + const SizedBox(width: 8), + Text('${user.firstName} ${user.lastName}'), + ], + ), + ], + ); + } +} diff --git a/test/helpers/test_helpers.dart b/test/helpers/test_helpers.dart index f8e453385..2dd3cc115 100644 --- a/test/helpers/test_helpers.dart +++ b/test/helpers/test_helpers.dart @@ -20,6 +20,7 @@ import 'package:talawa/services/chat_service.dart'; import 'package:talawa/services/comment_service.dart'; import 'package:talawa/services/database_mutation_functions.dart'; import 'package:talawa/services/event_service.dart'; +import 'package:talawa/services/fund_service.dart'; import 'package:talawa/services/graphql_config.dart'; import 'package:talawa/services/image_service.dart'; import 'package:talawa/services/navigation_service.dart'; @@ -71,6 +72,7 @@ import 'test_helpers.mocks.dart'; MockSpec(onMissingStub: OnMissingStub.returnDefault), MockSpec(onMissingStub: OnMissingStub.returnDefault), MockSpec(onMissingStub: OnMissingStub.returnDefault), + MockSpec(onMissingStub: OnMissingStub.returnDefault), MockSpec( onMissingStub: OnMissingStub.returnDefault, ), @@ -234,6 +236,20 @@ SessionManager getAndRegisterSessionManager() { return service; } +/// `getAndRegisterFundService` returns a mock instance of the `FundService` class. +/// +/// **params**: +/// None +/// +/// **returns**: +/// * `FundService`: A mock instance of the `FundService` class. +FundService getAndRegisterFundService() { + _removeRegistrationIfExists(); + final service = MockFundService(); + locator.registerSingleton(service); + return service; +} + /// `getAndRegisterChatService` returns a mock instance of the `ChatService` class. /// /// **params**: @@ -888,6 +904,7 @@ void registerServices() { getAndRegisterChatService(); getAndRegisterImageCropper(); getAndRegisterImagePicker(); + getAndRegisterFundService(); } /// `unregisterServices` unregisters all the services required for the test. diff --git a/test/helpers/test_helpers.mocks.dart b/test/helpers/test_helpers.mocks.dart index 417efbb51..03b8bec56 100644 --- a/test/helpers/test_helpers.mocks.dart +++ b/test/helpers/test_helpers.mocks.dart @@ -9,49 +9,53 @@ import 'dart:ui' as _i10; import 'package:flutter/material.dart' as _i1; import 'package:graphql_flutter/graphql_flutter.dart' as _i3; -import 'package:image_cropper/src/cropper.dart' as _i41; +import 'package:image_cropper/src/cropper.dart' as _i45; import 'package:image_cropper_platform_interface/image_cropper_platform_interface.dart' - as _i42; + as _i46; import 'package:image_picker/image_picker.dart' as _i13; import 'package:mockito/mockito.dart' as _i2; import 'package:mockito/src/dummies.dart' as _i18; -import 'package:qr_code_scanner/src/qr_code_scanner.dart' as _i33; -import 'package:qr_code_scanner/src/types/barcode.dart' as _i34; -import 'package:qr_code_scanner/src/types/camera.dart' as _i35; +import 'package:qr_code_scanner/src/qr_code_scanner.dart' as _i37; +import 'package:qr_code_scanner/src/types/barcode.dart' as _i38; +import 'package:qr_code_scanner/src/types/camera.dart' as _i39; import 'package:qr_code_scanner/src/types/features.dart' as _i12; import 'package:talawa/enums/enums.dart' as _i14; import 'package:talawa/models/chats/chat_list_tile_data_model.dart' as _i24; import 'package:talawa/models/chats/chat_message.dart' as _i25; import 'package:talawa/models/events/event_model.dart' as _i21; -import 'package:talawa/models/events/event_venue.dart' as _i39; +import 'package:talawa/models/events/event_venue.dart' as _i43; import 'package:talawa/models/events/event_volunteer_group.dart' as _i22; +import 'package:talawa/models/funds/fund.dart' as _i29; +import 'package:talawa/models/funds/fund_campaign.dart' as _i30; +import 'package:talawa/models/funds/fund_pledges.dart' as _i31; import 'package:talawa/models/organization/org_info.dart' as _i6; import 'package:talawa/models/post/post_model.dart' as _i17; import 'package:talawa/models/user/user_info.dart' as _i7; import 'package:talawa/services/chat_service.dart' as _i23; -import 'package:talawa/services/comment_service.dart' as _i36; +import 'package:talawa/services/comment_service.dart' as _i40; import 'package:talawa/services/database_mutation_functions.dart' as _i9; import 'package:talawa/services/event_service.dart' as _i11; +import 'package:talawa/services/fund_service.dart' as _i28; import 'package:talawa/services/graphql_config.dart' as _i15; import 'package:talawa/services/navigation_service.dart' as _i8; -import 'package:talawa/services/org_service.dart' as _i29; +import 'package:talawa/services/org_service.dart' as _i33; import 'package:talawa/services/post_service.dart' as _i16; import 'package:talawa/services/third_party_service/multi_media_pick_service.dart' as _i19; import 'package:talawa/services/user_config.dart' as _i26; -import 'package:talawa/utils/validators.dart' as _i32; +import 'package:talawa/utils/validators.dart' as _i36; import 'package:talawa/view_model/after_auth_view_models/chat_view_models/direct_chat_view_model.dart' - as _i40; + as _i44; import 'package:talawa/view_model/after_auth_view_models/event_view_models/create_event_view_model.dart' - as _i38; + as _i42; import 'package:talawa/view_model/after_auth_view_models/event_view_models/explore_events_view_model.dart' - as _i30; + as _i34; import 'package:talawa/view_model/after_auth_view_models/feed_view_models/organization_feed_view_model.dart' - as _i31; + as _i35; import 'package:talawa/view_model/lang_view_model.dart' as _i27; import 'package:talawa/view_model/pre_auth_view_models/signup_details_view_model.dart' - as _i28; -import 'package:talawa/view_model/theme_view_model.dart' as _i37; + as _i32; +import 'package:talawa/view_model/theme_view_model.dart' as _i41; import 'package:talawa/widgets/custom_alert_dialog.dart' as _i4; // ignore_for_file: type=lint @@ -2160,11 +2164,138 @@ class MockAppLanguage extends _i2.Mock implements _i27.AppLanguage { ); } +/// A class which mocks [FundService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFundService extends _i2.Mock implements _i28.FundService { + @override + _i5.Future> getFunds({String? orderBy = r'createdAt_DESC'}) => + (super.noSuchMethod( + Invocation.method( + #getFunds, + [], + {#orderBy: orderBy}, + ), + returnValue: _i5.Future>.value(<_i29.Fund>[]), + returnValueForMissingStub: + _i5.Future>.value(<_i29.Fund>[]), + ) as _i5.Future>); + + @override + _i5.Future> getCampaigns( + String? fundId, { + String? orderBy = r'endDate_DESC', + }) => + (super.noSuchMethod( + Invocation.method( + #getCampaigns, + [fundId], + {#orderBy: orderBy}, + ), + returnValue: _i5.Future>.value(<_i30.Campaign>[]), + returnValueForMissingStub: + _i5.Future>.value(<_i30.Campaign>[]), + ) as _i5.Future>); + + @override + _i5.Future> getPledgesByCampaign( + String? campaignId, { + String? orderBy = r'endDate_DESC', + }) => + (super.noSuchMethod( + Invocation.method( + #getPledgesByCampaign, + [campaignId], + {#orderBy: orderBy}, + ), + returnValue: _i5.Future>.value(<_i31.Pledge>[]), + returnValueForMissingStub: + _i5.Future>.value(<_i31.Pledge>[]), + ) as _i5.Future>); + + @override + _i5.Future<_i3.QueryResult> createPledge( + Map? variables) => + (super.noSuchMethod( + Invocation.method( + #createPledge, + [variables], + ), + returnValue: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #createPledge, + [variables], + ), + )), + returnValueForMissingStub: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #createPledge, + [variables], + ), + )), + ) as _i5.Future<_i3.QueryResult>); + + @override + _i5.Future<_i3.QueryResult> updatePledge( + Map? variables) => + (super.noSuchMethod( + Invocation.method( + #updatePledge, + [variables], + ), + returnValue: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #updatePledge, + [variables], + ), + )), + returnValueForMissingStub: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #updatePledge, + [variables], + ), + )), + ) as _i5.Future<_i3.QueryResult>); + + @override + _i5.Future<_i3.QueryResult> deletePledge(String? pledgeId) => + (super.noSuchMethod( + Invocation.method( + #deletePledge, + [pledgeId], + ), + returnValue: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #deletePledge, + [pledgeId], + ), + )), + returnValueForMissingStub: _i5.Future<_i3.QueryResult>.value( + _FakeQueryResult_9( + this, + Invocation.method( + #deletePledge, + [pledgeId], + ), + )), + ) as _i5.Future<_i3.QueryResult>); +} + /// A class which mocks [SignupDetailsViewModel]. /// /// See the documentation for Mockito's code generation for more information. class MockSignupDetailsViewModel extends _i2.Mock - implements _i28.SignupDetailsViewModel { + implements _i32.SignupDetailsViewModel { @override _i1.GlobalKey<_i1.FormState> get formKey => (super.noSuchMethod( Invocation.getter(#formKey), @@ -2847,7 +2978,7 @@ class MockDataBaseMutationFunctions extends _i2.Mock /// /// See the documentation for Mockito's code generation for more information. class MockOrganizationService extends _i2.Mock - implements _i29.OrganizationService { + implements _i33.OrganizationService { @override _i5.Future> getOrgMembersList(String? orgId) => (super.noSuchMethod( @@ -2865,7 +2996,7 @@ class MockOrganizationService extends _i2.Mock /// /// See the documentation for Mockito's code generation for more information. class MockExploreEventsViewModel extends _i2.Mock - implements _i30.ExploreEventsViewModel { + implements _i34.ExploreEventsViewModel { @override bool get demoMode => (super.noSuchMethod( Invocation.getter(#demoMode), @@ -3069,7 +3200,7 @@ class MockExploreEventsViewModel extends _i2.Mock /// /// See the documentation for Mockito's code generation for more information. class MockOrganizationFeedViewModel extends _i2.Mock - implements _i31.OrganizationFeedViewModel { + implements _i35.OrganizationFeedViewModel { @override bool get istest => (super.noSuchMethod( Invocation.getter(#istest), @@ -3299,7 +3430,7 @@ class MockOrganizationFeedViewModel extends _i2.Mock /// A class which mocks [Validator]. /// /// See the documentation for Mockito's code generation for more information. -class MockValidator extends _i2.Mock implements _i32.Validator { +class MockValidator extends _i2.Mock implements _i36.Validator { @override _i5.Future validateUrlExistence(String? url) => (super.noSuchMethod( Invocation.method( @@ -3314,13 +3445,13 @@ class MockValidator extends _i2.Mock implements _i32.Validator { /// A class which mocks [QRViewController]. /// /// See the documentation for Mockito's code generation for more information. -class MockQRViewController extends _i2.Mock implements _i33.QRViewController { +class MockQRViewController extends _i2.Mock implements _i37.QRViewController { @override - _i5.Stream<_i34.Barcode> get scannedDataStream => (super.noSuchMethod( + _i5.Stream<_i38.Barcode> get scannedDataStream => (super.noSuchMethod( Invocation.getter(#scannedDataStream), - returnValue: _i5.Stream<_i34.Barcode>.empty(), - returnValueForMissingStub: _i5.Stream<_i34.Barcode>.empty(), - ) as _i5.Stream<_i34.Barcode>); + returnValue: _i5.Stream<_i38.Barcode>.empty(), + returnValueForMissingStub: _i5.Stream<_i38.Barcode>.empty(), + ) as _i5.Stream<_i38.Barcode>); @override bool get hasPermissions => (super.noSuchMethod( @@ -3330,28 +3461,28 @@ class MockQRViewController extends _i2.Mock implements _i33.QRViewController { ) as bool); @override - _i5.Future<_i35.CameraFacing> getCameraInfo() => (super.noSuchMethod( + _i5.Future<_i39.CameraFacing> getCameraInfo() => (super.noSuchMethod( Invocation.method( #getCameraInfo, [], ), returnValue: - _i5.Future<_i35.CameraFacing>.value(_i35.CameraFacing.back), + _i5.Future<_i39.CameraFacing>.value(_i39.CameraFacing.back), returnValueForMissingStub: - _i5.Future<_i35.CameraFacing>.value(_i35.CameraFacing.back), - ) as _i5.Future<_i35.CameraFacing>); + _i5.Future<_i39.CameraFacing>.value(_i39.CameraFacing.back), + ) as _i5.Future<_i39.CameraFacing>); @override - _i5.Future<_i35.CameraFacing> flipCamera() => (super.noSuchMethod( + _i5.Future<_i39.CameraFacing> flipCamera() => (super.noSuchMethod( Invocation.method( #flipCamera, [], ), returnValue: - _i5.Future<_i35.CameraFacing>.value(_i35.CameraFacing.back), + _i5.Future<_i39.CameraFacing>.value(_i39.CameraFacing.back), returnValueForMissingStub: - _i5.Future<_i35.CameraFacing>.value(_i35.CameraFacing.back), - ) as _i5.Future<_i35.CameraFacing>); + _i5.Future<_i39.CameraFacing>.value(_i39.CameraFacing.back), + ) as _i5.Future<_i39.CameraFacing>); @override _i5.Future getFlashStatus() => (super.noSuchMethod( @@ -3450,7 +3581,7 @@ class MockQRViewController extends _i2.Mock implements _i33.QRViewController { /// A class which mocks [CommentService]. /// /// See the documentation for Mockito's code generation for more information. -class MockCommentService extends _i2.Mock implements _i36.CommentService { +class MockCommentService extends _i2.Mock implements _i40.CommentService { @override _i5.Future createComments( String? postId, @@ -3483,7 +3614,7 @@ class MockCommentService extends _i2.Mock implements _i36.CommentService { /// A class which mocks [AppTheme]. /// /// See the documentation for Mockito's code generation for more information. -class MockAppTheme extends _i2.Mock implements _i37.AppTheme { +class MockAppTheme extends _i2.Mock implements _i41.AppTheme { @override String get key => (super.noSuchMethod( Invocation.getter(#key), @@ -3607,7 +3738,7 @@ class MockAppTheme extends _i2.Mock implements _i37.AppTheme { /// /// See the documentation for Mockito's code generation for more information. class MockCreateEventViewModel extends _i2.Mock - implements _i38.CreateEventViewModel { + implements _i42.CreateEventViewModel { @override _i1.TextEditingController get eventTitleTextController => (super.noSuchMethod( Invocation.getter(#eventTitleTextController), @@ -4279,15 +4410,15 @@ class MockCreateEventViewModel extends _i2.Mock ); @override - _i5.Future> fetchVenues() => (super.noSuchMethod( + _i5.Future> fetchVenues() => (super.noSuchMethod( Invocation.method( #fetchVenues, [], ), - returnValue: _i5.Future>.value(<_i39.Venue>[]), + returnValue: _i5.Future>.value(<_i43.Venue>[]), returnValueForMissingStub: - _i5.Future>.value(<_i39.Venue>[]), - ) as _i5.Future>); + _i5.Future>.value(<_i43.Venue>[]), + ) as _i5.Future>); @override void setState(_i14.ViewState? viewState) => super.noSuchMethod( @@ -4339,7 +4470,7 @@ class MockCreateEventViewModel extends _i2.Mock /// /// See the documentation for Mockito's code generation for more information. class MockDirectChatViewModel extends _i2.Mock - implements _i40.DirectChatViewModel { + implements _i44.DirectChatViewModel { @override _i1.GlobalKey<_i1.AnimatedListState> get listKey => (super.noSuchMethod( Invocation.getter(#listKey), @@ -4518,24 +4649,24 @@ class MockDirectChatViewModel extends _i2.Mock /// A class which mocks [ImageCropper]. /// /// See the documentation for Mockito's code generation for more information. -class MockImageCropper extends _i2.Mock implements _i41.ImageCropper { +class MockImageCropper extends _i2.Mock implements _i45.ImageCropper { @override - _i5.Future<_i42.CroppedFile?> cropImage({ + _i5.Future<_i46.CroppedFile?> cropImage({ required String? sourcePath, int? maxWidth, int? maxHeight, - _i42.CropAspectRatio? aspectRatio, - List<_i42.CropAspectRatioPreset>? aspectRatioPresets = const [ - _i42.CropAspectRatioPreset.original, - _i42.CropAspectRatioPreset.square, - _i42.CropAspectRatioPreset.ratio3x2, - _i42.CropAspectRatioPreset.ratio4x3, - _i42.CropAspectRatioPreset.ratio16x9, + _i46.CropAspectRatio? aspectRatio, + List<_i46.CropAspectRatioPreset>? aspectRatioPresets = const [ + _i46.CropAspectRatioPreset.original, + _i46.CropAspectRatioPreset.square, + _i46.CropAspectRatioPreset.ratio3x2, + _i46.CropAspectRatioPreset.ratio4x3, + _i46.CropAspectRatioPreset.ratio16x9, ], - _i42.CropStyle? cropStyle = _i42.CropStyle.rectangle, - _i42.ImageCompressFormat? compressFormat = _i42.ImageCompressFormat.jpg, + _i46.CropStyle? cropStyle = _i46.CropStyle.rectangle, + _i46.ImageCompressFormat? compressFormat = _i46.ImageCompressFormat.jpg, int? compressQuality = 90, - List<_i42.PlatformUiSettings>? uiSettings, + List<_i46.PlatformUiSettings>? uiSettings, }) => (super.noSuchMethod( Invocation.method( @@ -4553,19 +4684,19 @@ class MockImageCropper extends _i2.Mock implements _i41.ImageCropper { #uiSettings: uiSettings, }, ), - returnValue: _i5.Future<_i42.CroppedFile?>.value(), - returnValueForMissingStub: _i5.Future<_i42.CroppedFile?>.value(), - ) as _i5.Future<_i42.CroppedFile?>); + returnValue: _i5.Future<_i46.CroppedFile?>.value(), + returnValueForMissingStub: _i5.Future<_i46.CroppedFile?>.value(), + ) as _i5.Future<_i46.CroppedFile?>); @override - _i5.Future<_i42.CroppedFile?> recoverImage() => (super.noSuchMethod( + _i5.Future<_i46.CroppedFile?> recoverImage() => (super.noSuchMethod( Invocation.method( #recoverImage, [], ), - returnValue: _i5.Future<_i42.CroppedFile?>.value(), - returnValueForMissingStub: _i5.Future<_i42.CroppedFile?>.value(), - ) as _i5.Future<_i42.CroppedFile?>); + returnValue: _i5.Future<_i46.CroppedFile?>.value(), + returnValueForMissingStub: _i5.Future<_i46.CroppedFile?>.value(), + ) as _i5.Future<_i46.CroppedFile?>); } /// A class which mocks [ImagePicker]. diff --git a/test/helpers/test_locator.dart b/test/helpers/test_locator.dart index 5d0b80a7f..0bb271417 100644 --- a/test/helpers/test_locator.dart +++ b/test/helpers/test_locator.dart @@ -10,6 +10,7 @@ import 'package:talawa/services/caching/cache_service.dart'; import 'package:talawa/services/comment_service.dart'; import 'package:talawa/services/database_mutation_functions.dart'; import 'package:talawa/services/event_service.dart'; +import 'package:talawa/services/fund_service.dart'; import 'package:talawa/services/graphql_config.dart'; import 'package:talawa/services/image_service.dart'; import 'package:talawa/services/navigation_service.dart'; @@ -34,6 +35,7 @@ import 'package:talawa/view_model/after_auth_view_models/event_view_models/event 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/event_view_models/manage_volunteer_group_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/feed_view_models/organization_feed_view_model.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/profile_view_models/edit_profile_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/profile_view_models/profile_page_view_model.dart'; import 'package:talawa/view_model/after_auth_view_models/settings_view_models/app_setting_view_model.dart'; @@ -95,6 +97,7 @@ void testSetupLocator() { //Services locator.registerLazySingleton(() => OrganizationService()); locator.registerLazySingleton(() => PostService()); + locator.registerLazySingleton(() => FundService()); locator.registerLazySingleton(() => EventService()); locator.registerLazySingleton(() => CommentService()); locator.registerLazySingleton(() => Connectivity()); @@ -121,6 +124,7 @@ void testSetupLocator() { locator.registerFactory(() => DemoViewModel()); // locator.registerFactory(() => OrganizationFeedViewModel()); locator.registerFactory(() => OrganizationFeedViewModel()); + locator.registerFactory(() => FundViewModel()); locator.registerFactory(() => SetUrlViewModel()); locator.registerFactory(() => LoginViewModel()); locator.registerFactory(() => ManageVolunteerGroupViewModel()); diff --git a/test/model_tests/funds/fund_campaign_test.dart b/test/model_tests/funds/fund_campaign_test.dart new file mode 100644 index 000000000..07529c3ef --- /dev/null +++ b/test/model_tests/funds/fund_campaign_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; + +void main() { + group('Test Campaign Model', () { + test('Test Campaign fromJson', () { + final campaignJson = { + '_id': 'campaign1', + 'fundId': 'fund1', + 'name': 'Annual Fundraiser', + 'startDate': '2024-01-01T00:00:00Z', + 'endDate': '2024-12-31T23:59:59Z', + 'fundingGoal': 10000.0, + 'currency': 'USD', + 'pledges': [Pledge(id: 'pledge1')], + 'createdAt': '2024-01-01T00:00:00Z', + 'updatedAt': '2024-01-02T00:00:00Z', + }; + + final campaign = Campaign.fromJson(campaignJson); + + expect(campaign.id, 'campaign1'); + expect(campaign.fundId, 'fund1'); + expect(campaign.name, 'Annual Fundraiser'); + expect(campaign.startDate, DateTime.parse('2024-01-01T00:00:00Z')); + expect(campaign.endDate, DateTime.parse('2024-12-31T23:59:59Z')); + expect(campaign.fundingGoal, 10000.0); + expect(campaign.currency, 'USD'); + expect(campaign.pledges?.length, 1); + expect(campaign.pledges?[0].id, 'pledge1'); + expect(campaign.createdAt, DateTime.parse('2024-01-01T00:00:00Z')); + expect(campaign.updatedAt, DateTime.parse('2024-01-02T00:00:00Z')); + }); + + test('Test Campaign fromJson with null values', () { + final campaignJson = { + '_id': null, + 'fundId': null, + 'name': null, + 'startDate': null, + 'endDate': null, + 'fundingGoal': null, + 'currency': null, + 'pledges': null, + 'createdAt': null, + 'updatedAt': null, + }; + + final campaign = Campaign.fromJson(campaignJson); + + expect(campaign.id, isNull); + expect(campaign.fundId, isNull); + expect(campaign.name, isNull); + expect(campaign.startDate, isNull); + expect(campaign.endDate, isNull); + expect(campaign.fundingGoal, isNull); + expect(campaign.currency, isNull); + expect(campaign.pledges, isNull); + expect(campaign.createdAt, isNull); + expect(campaign.updatedAt, isNull); + }); + }); +} diff --git a/test/model_tests/funds/fund_pledges_test.dart b/test/model_tests/funds/fund_pledges_test.dart new file mode 100644 index 000000000..23dc407e1 --- /dev/null +++ b/test/model_tests/funds/fund_pledges_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; + +void main() { + group('Pledge Model Test', () { + test('fromJson should parse JSON correctly', () { + // Sample JSON data representing a pledge + final jsonData = { + '_id': 'pledge123', + 'campaigns': [ + Campaign(name: 'Education Fund'), + Campaign(name: 'Health Fund'), + ], + 'users': [ + { + 'firstName': 'Alice', + }, + { + 'firstName': 'Bob', + }, + ], + 'startDate': '2024-01-01T00:00:00.000Z', + 'endDate': '2024-12-31T23:59:59.999Z', + 'amount': 500, + 'currency': 'USD', + 'createdAt': '2024-01-01T00:00:00.000Z', + 'updatedAt': '2024-06-30T00:00:00.000Z', + }; + + // Parse the JSON data into a Pledge instance + final pledge = Pledge.fromJson(jsonData); + + // Assertions + expect(pledge.id, 'pledge123'); + expect(pledge.campaigns, isA>()); + expect(pledge.campaigns?.length, 2); + expect(pledge.campaigns?[0].name, 'Education Fund'); + expect(pledge.campaigns?[1].name, 'Health Fund'); + + expect(pledge.pledgers, isA>()); + expect(pledge.pledgers?.length, 2); + expect(pledge.pledgers?[0].firstName, 'Alice'); + expect(pledge.pledgers?[1].firstName, 'Bob'); + + expect(pledge.startDate, DateTime.parse('2024-01-01T00:00:00.000Z')); + expect(pledge.endDate, DateTime.parse('2024-12-31T23:59:59.999Z')); + expect(pledge.amount, 500); + expect(pledge.currency, 'USD'); + expect(pledge.createdAt, DateTime.parse('2024-01-01T00:00:00.000Z')); + expect(pledge.updatedAt, DateTime.parse('2024-06-30T00:00:00.000Z')); + }); + test('fromJson should handle null values correctly', () { + // Sample JSON data with null values + final jsonData = { + '_id': null, + 'campaigns': null, + 'users': null, + 'startDate': null, + 'endDate': null, + 'amount': null, + 'currency': null, + 'createdAt': null, + 'updatedAt': null, + }; + + // Parse the JSON data into a Pledge instance + final pledge = Pledge.fromJson(jsonData); + + // Assertions for null values + expect(pledge.id, isNull); + expect(pledge.campaigns, isNull); + expect(pledge.pledgers, isNull); + expect(pledge.startDate, isNull); + expect(pledge.endDate, isNull); + expect(pledge.amount, isNull); + expect(pledge.currency, isNull); + expect(pledge.createdAt, isNull); + expect(pledge.updatedAt, isNull); + }); + }); +} diff --git a/test/model_tests/funds/fund_test.dart b/test/model_tests/funds/fund_test.dart new file mode 100644 index 000000000..709480a3d --- /dev/null +++ b/test/model_tests/funds/fund_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; + +void main() { + group('Test Fund Model', () { + test('Test Fund fromJson', () { + final fundJson = { + '_id': 'fund1', + 'organizationId': 'org1', + 'name': 'General Fund', + 'taxDeductible': true, + 'isDefault': false, + 'isArchived': false, + 'creator': { + '_id': 'user1', + 'firstName': 'John', + 'lastName': 'Doe', + }, + 'campaigns': [Campaign(id: 'campaign1')], + 'createdAt': '2024-01-01T00:00:00Z', + 'updatedAt': '2024-01-02T00:00:00Z', + }; + + final fund = Fund.fromJson(fundJson); + + expect(fund.id, 'fund1'); + expect(fund.organizationId, 'org1'); + expect(fund.name, 'General Fund'); + expect(fund.taxDeductible, true); + expect(fund.isDefault, false); + expect(fund.isArchived, false); + expect(fund.creator?.id, 'user1'); + expect(fund.campaigns?.length, 1); + expect(fund.campaigns?[0].id, 'campaign1'); + expect(fund.createdAt, DateTime.parse('2024-01-01T00:00:00Z')); + expect(fund.updatedAt, DateTime.parse('2024-01-02T00:00:00Z')); + }); + + test('Test Fund fromJson with null values', () { + final fundJson = { + '_id': null, + 'organizationId': null, + 'name': null, + 'referenceNumber': null, + 'taxDeductible': null, + 'isDefault': null, + 'isArchived': null, + 'creator': null, + 'campaigns': null, + 'createdAt': null, + 'updatedAt': null, + }; + + final fund = Fund.fromJson(fundJson); + + expect(fund.id, isNull); + expect(fund.organizationId, isNull); + expect(fund.name, isNull); + expect(fund.taxDeductible, isNull); + expect(fund.isDefault, isNull); + expect(fund.isArchived, isNull); + expect(fund.creator, isNull); + expect(fund.campaigns, isNull); + expect(fund.createdAt, isNull); + expect(fund.updatedAt, isNull); + }); + }); +} diff --git a/test/service_tests/fund_service_test.dart b/test/service_tests/fund_service_test.dart new file mode 100644 index 000000000..9f4d5e409 --- /dev/null +++ b/test/service_tests/fund_service_test.dart @@ -0,0 +1,250 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:talawa/locator.dart'; +import 'package:talawa/services/database_mutation_functions.dart'; +import 'package:talawa/services/fund_service.dart'; +import 'package:talawa/utils/fund_queries.dart'; +import '../helpers/test_helpers.dart'; + +void main() { + setUp(() { + registerServices(); + }); + + group('Test FundService', () { + test('Test getFunds Method', () async { + final dataBaseMutationFunctions = locator(); + const orgId = "XYZ"; + final query = FundQueries().fetchOrgFunds(); + + when( + dataBaseMutationFunctions.gqlAuthQuery( + query, + variables: { + 'orgId': orgId, + 'filter': '', + 'orderBy': 'createdAt_DESC', + }, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'fundsByOrganization': [ + { + '_id': 'fund_1', + 'name': 'Fund 1', + }, + { + '_id': 'fund_2', + 'name': 'Fund 2', + }, + ], + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final funds = await service.getFunds(); + + expect(funds.length, 2); + expect(funds[0].id, 'fund_1'); + expect(funds[0].name, 'Fund 1'); + expect(funds[1].id, 'fund_2'); + expect(funds[1].name, 'Fund 2'); + }); + + test('Test getCampaigns Method', () async { + final dataBaseMutationFunctions = locator(); + const fundId = "fund_123"; + final query = FundQueries().fetchCampaignsByFund(); + + when( + dataBaseMutationFunctions.gqlAuthQuery( + query, + variables: { + 'fundId': fundId, + 'where': { + 'fundId': fundId, + }, + 'pledgeOrderBy': 'endDate_DESC', + }, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'getFundById': { + 'campaigns': [ + { + '_id': 'campaign_1', + 'name': 'Campaign 1', + }, + { + '_id': 'campaign_2', + 'name': 'Campaign 2', + }, + ], + }, + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final campaigns = await service.getCampaigns(fundId); + + expect(campaigns.length, 2); + expect(campaigns[0].id, 'campaign_1'); + expect(campaigns[0].name, 'Campaign 1'); + expect(campaigns[1].id, 'campaign_2'); + expect(campaigns[1].name, 'Campaign 2'); + }); + + test('Test getPledgesByCampaign Method', () async { + final dataBaseMutationFunctions = locator(); + const campaignId = "campaign_123"; + final query = FundQueries().fetchPledgesByCampaign(); + + when( + dataBaseMutationFunctions.gqlAuthQuery( + query, + variables: { + 'where': { + 'id': campaignId, + }, + 'pledgeOrderBy': 'endDate_DESC', + }, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'getFundraisingCampaigns': [ + { + 'pledges': [ + { + '_id': 'pledge_1', + 'amount': 100, + }, + { + '_id': 'pledge_2', + 'amount': 200, + }, + ], + }, + ], + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final pledges = await service.getPledgesByCampaign(campaignId); + + expect(pledges.length, 2); + expect(pledges[0].id, 'pledge_1'); + expect(pledges[0].amount, 100); + expect(pledges[1].id, 'pledge_2'); + expect(pledges[1].amount, 200); + }); + + test('Test createPledge Method', () async { + final dataBaseMutationFunctions = locator(); + final query = FundQueries().createPledge(); + final variables = { + 'campaignId': 'campaign_123', + 'amount': 50, + }; + + when( + dataBaseMutationFunctions.gqlAuthMutation( + query, + variables: variables, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'createPledge': { + 'id': 'pledge_123', + 'amount': 50, + }, + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final result = await service.createPledge(variables); + + expect(result.data?['createPledge']['id'], 'pledge_123'); + expect(result.data?['createPledge']['amount'], 50); + }); + + test('Test updatePledge Method', () async { + final dataBaseMutationFunctions = locator(); + final query = FundQueries().updatePledge(); + final variables = { + 'id': 'pledge_123', + 'amount': 150, + }; + + when( + dataBaseMutationFunctions.gqlAuthMutation( + query, + variables: variables, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'updatePledge': { + 'id': 'pledge_123', + 'amount': 150, + }, + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final result = await service.updatePledge(variables); + + expect(result.data?['updatePledge']['id'], 'pledge_123'); + expect(result.data?['updatePledge']['amount'], 150); + }); + + test('Test deletePledge Method', () async { + final dataBaseMutationFunctions = locator(); + final query = FundQueries().deletePledge(); + const pledgeId = 'pledge_123'; + + when( + dataBaseMutationFunctions.gqlAuthMutation( + query, + variables: {'id': pledgeId}, + ), + ).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql(query)), + data: { + 'deletePledge': { + 'id': pledgeId, + }, + }, + source: QueryResultSource.network, + ), + ); + + final service = FundService(); + final result = await service.deletePledge(pledgeId); + + expect(result.data?['deletePledge']['id'], pledgeId); + }); + }); +} diff --git a/test/service_tests/image_service_test.dart b/test/service_tests/image_service_test.dart index 399fbda7b..68351ed39 100644 --- a/test/service_tests/image_service_test.dart +++ b/test/service_tests/image_service_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_cropper/image_cropper.dart'; @@ -19,6 +20,12 @@ class MockImageService extends Mock implements ImageService { if (file.path == throwException) throw Exception('fake exception'); return "base64"; } + + @override + Uint8List? decodeBase64(String base64String) { + if (base64String == throwException) return null; + return Uint8List.fromList([1, 2, 3]); + } } void main() { @@ -28,7 +35,7 @@ void main() { registerServices(); }); - group('Tests for Crop Image', () { + group('Tests for Image Service', () { test("test no image provided for the image cropper", () async { const path = 'test'; final file = await imageService.cropImage(imageFile: File(path)); @@ -77,27 +84,94 @@ void main() { throwsException, ); }); - }); - group('Tests for convertToBase64', () { - test('convertToBase64 converts file to base64 string', () async { - //using this asset as the test asset - final file = File('assets/images/Group 8948.png'); - final List encodedBytes = file.readAsBytesSync(); + group('Tests for convertToBase64', () { + test('convertToBase64 converts file to base64 string', () async { + final file = File('assets/images/Group 8948.png'); + final List encodedBytes = file.readAsBytesSync(); + + final fileString = await imageService.convertToBase64(file); + + final List decodedBytes = base64Decode(fileString.split(',').last); + + expect(decodedBytes, equals(encodedBytes)); + }); - final fileString = await imageService.convertToBase64(file); + test('convertToBase64 includes correct MIME type for PNG', () async { + final file = File('assets/images/Group 8948.png'); + final fileString = await imageService.convertToBase64(file); - final List decodedBytes = base64Decode(fileString); + expect(fileString.startsWith('data:image/png;base64,'), isTrue); + }); - expect(decodedBytes, equals(encodedBytes)); + test( + 'Check if convertToBase64 is working even if wrong file path is provided', + () async { + final file = File('fakePath'); + final fileString = await imageService.convertToBase64(file); + expect('', fileString); + }); }); - test( - 'Check if convertToBase64 is working even if wrong file path is provided', - () async { - final file = File('fakePath'); - final fileString = await imageService.convertToBase64(file); - expect('', fileString); + group('Tests for decodeBase64', () { + test('decodeBase64 successfully decodes valid base64 string', () { + const validBase64 = 'data:image/png;base64,SGVsbG8gV29ybGQ='; + final result = imageService.decodeBase64(validBase64); + + expect(result, isNotNull); + expect(result, isA()); + }); + + test('decodeBase64 handles base64 string without MIME type prefix', () { + const validBase64 = 'SGVsbG8gV29ybGQ='; + final result = imageService.decodeBase64(validBase64); + + expect(result, isNotNull); + expect(result, isA()); + }); + + test('decodeBase64 returns null for invalid base64 string', () { + const invalidBase64 = 'invalid-base64-string'; + final result = imageService.decodeBase64(invalidBase64); + + expect(result, isNull); + }); + }); + + group('Tests for MIME type detection', () { + test('correctly identifies JPEG image', () async { + final jpegBytes = [0xFF, 0xD8, 0xFF, 0xE0]; + final file = await File('test.jpg').writeAsBytes(jpegBytes); + + final base64String = await imageService.convertToBase64(file); + + expect(base64String.startsWith('data:image/jpeg;base64,'), isTrue); + + await file.delete(); + }); + + test('correctly identifies PNG image', () async { + final pngBytes = [0x89, 0x50, 0x4E, 0x47]; + final file = await File('test.png').writeAsBytes(pngBytes); + + final base64String = await imageService.convertToBase64(file); + + expect(base64String.startsWith('data:image/png;base64,'), isTrue); + + await file.delete(); + }); + + test('handles unknown image format', () async { + final unknownBytes = [0x00, 0x00, 0x00, 0x00]; + final file = await File('test.bin').writeAsBytes(unknownBytes); + + final base64String = await imageService.convertToBase64(file); + + // Should return plain base64 without MIME type prefix + expect(base64String.startsWith('data:'), isFalse); + + await file.delete(); + }); }); }); } diff --git a/test/utils/fund_queries_test.dart b/test/utils/fund_queries_test.dart new file mode 100644 index 000000000..e47dd22fb --- /dev/null +++ b/test/utils/fund_queries_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:talawa/utils/fund_queries.dart'; + +void main() { + group("Tests for FundQueries", () { + test("Check if fetchOrgFunds works correctly", () { + const data = ''' + query FundsByOrganization( + \$orgId: ID! + \$filter: String + \$orderBy: FundOrderByInput + ) { + fundsByOrganization( + organizationId: \$orgId + where: { name_contains: \$filter } + orderBy: \$orderBy + ) { + _id + name + refrenceNumber + taxDeductible + isDefault + isArchived + createdAt + organizationId + creator { + _id + firstName + lastName + } + } + } + '''; + final fnData = FundQueries().fetchOrgFunds(); + expect(fnData, data); + }); + + test("Check if fetchCampaignsByFund works correctly", () { + const data = ''' + query GetFundById( + \$fundId: ID! + \$where: CampaignWhereInput + \$orderBy: CampaignOrderByInput + ) { + getFundById(id: \$fundId, where: \$where, orderBy: \$orderBy) { + campaigns { + _id + endDate + fundingGoal + name + startDate + currency + } + } + } + '''; + final fnData = FundQueries().fetchCampaignsByFund(); + expect(fnData, data); + }); + + test("Check if fetchPledgesByCampaign works correctly", () { + const data = ''' + query GetFundraisingCampaigns( + \$where: CampaignWhereInput + \$pledgeOrderBy: PledgeOrderByInput + ) { + getFundraisingCampaigns(where: \$where, pledgeOrderBy: \$pledgeOrderBy) { + name + fundingGoal + currency + startDate + endDate + pledges { + _id + amount + currency + endDate + startDate + users { + _id + firstName + lastName + image + } + } + } + } + '''; + final fnData = FundQueries().fetchPledgesByCampaign(); + expect(fnData, data); + }); + + test("Check if fetchUserCampaigns works correctly", () { + const data = ''' + query GetFundraisingCampaigns( + \$where: CampaignWhereInput + \$campaignOrderBy: CampaignOrderByInput + ) { + getFundraisingCampaigns(where: \$where, campaignOrderby: \$campaignOrderBy) { + _id + startDate + endDate + name + fundingGoal + currency + } + } + '''; + final fnData = FundQueries().fetchUserCampaigns(); + expect(fnData, data); + }); + + test("Check if fetchUserPledges works correctly", () { + const data = ''' + query GetPledgesByUserId( + \$userId: ID! + \$where: PledgeWhereInput + \$orderBy: PledgeOrderByInput + ) { + getPledgesByUserId(userId: \$userId, where: \$where, orderBy: \$orderBy) { + _id + amount + startDate + endDate + campaign { + _id + name + endDate + } + currency + users { + _id + firstName + lastName + image + } + } + } + '''; + final fnData = FundQueries().fetchUserPledges(); + expect(fnData, data); + }); + + test("Check if createPledge works correctly", () { + const data = ''' + mutation CreateFundraisingCampaignPledge( + \$campaignId: ID! + \$amount: Float! + \$currency: Currency! + \$startDate: Date! + \$endDate: Date! + \$userIds: [ID!]! + ) { + createFundraisingCampaignPledge( + data: { + campaignId: \$campaignId + amount: \$amount + currency: \$currency + startDate: \$startDate + endDate: \$endDate + userIds: \$userIds + } + ) { + _id + } + } + '''; + final fnData = FundQueries().createPledge(); + expect(fnData, data); + }); + + test("Check if updatePledge works correctly", () { + const data = ''' + mutation UpdateFundraisingCampaignPledge( + \$id: ID! + \$amount: Float + \$currency: Currency + \$startDate: Date + \$endDate: Date + \$users: [ID!] + ) { + updateFundraisingCampaignPledge( + id: \$id + data: { + users: \$users + amount: \$amount + currency: \$currency + startDate: \$startDate + endDate: \$endDate + } + ) { + _id + + } + } + '''; + final fnData = FundQueries().updatePledge(); + expect(fnData, data); + }); + + test("Check if deletePledge works correctly", () { + const data = ''' + mutation DeleteFundraisingCampaignPledge(\$id: ID!) { + removeFundraisingCampaignPledge(id: \$id) { + _id + } + } + '''; + final fnData = FundQueries().deletePledge(); + expect(fnData, data); + }); + }); +} diff --git a/test/view_model_tests/after_auth_view_model_tests/fund_view_models/fund_view_model_test.dart b/test/view_model_tests/after_auth_view_model_tests/fund_view_models/fund_view_model_test.dart new file mode 100644 index 000000000..60838485f --- /dev/null +++ b/test/view_model_tests/after_auth_view_model_tests/fund_view_models/fund_view_model_test.dart @@ -0,0 +1,299 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/services/fund_service.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + testSetupLocator(); + registerServices(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('FundViewModel Tests', () { + late FundViewModel model; + + setUp(() { + model = FundViewModel(); + }); + + test('Test initialization', () async { + model.initialise(); + + expect(model.isFetchingFunds, true); + expect(model.isFetchingCampaigns, true); + expect(model.isFetchingPledges, true); + expect(model.funds, isEmpty); + expect(model.campaigns, isEmpty); + expect(model.allPledges, isEmpty); + }); + + group('Fund Tests', () { + test('Test fetchFunds success', () async { + final mockFundService = locator(); + final mockFunds = [ + Fund(id: '1', name: 'Fund 1'), + Fund(id: '2', name: 'Fund 2'), + ]; + + when(mockFundService.getFunds(orderBy: 'createdAt_DESC')) + .thenAnswer((_) async => mockFunds); + + await model.fetchFunds(); + + expect(model.funds.length, 2); + expect(model.funds[0].id, '1'); + expect(model.isFetchingFunds, false); + }); + + test('Test searchFunds filters correctly', () async { + final mockFundService = locator(); + final mockFunds = [ + Fund(id: '1', name: 'Education Fund'), + Fund(id: '2', name: 'Health Fund'), + ]; + + when(mockFundService.getFunds(orderBy: 'createdAt_DESC')) + .thenAnswer((_) async => mockFunds); + + await model.fetchFunds(); + model.searchFunds('health'); + + expect(model.filteredFunds.length, 1); + expect(model.filteredFunds[0].name, 'Health Fund'); + }); + + test('Test sortFunds changes sort option and refetches', () async { + final mockFundService = locator(); + + when(mockFundService.getFunds(orderBy: 'name_ASC')) + .thenAnswer((_) async => []); + + model.sortFunds('name_ASC'); + + expect(model.fundSortOption, 'name_ASC'); + verify(mockFundService.getFunds(orderBy: 'name_ASC')).called(1); + }); + }); + + group('Campaign Tests', () { + test('Test fetchCampaigns success', () async { + final mockFundService = locator(); + final mockCampaigns = [ + Campaign(id: '1', name: 'Campaign 1'), + Campaign(id: '2', name: 'Campaign 2'), + ]; + + when(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockCampaigns); + + await model.fetchCampaigns('1'); + + expect(model.campaigns.length, 2); + expect(model.campaigns[0].id, '1'); + expect(model.isFetchingCampaigns, false); + verify(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .called(1); + }); + + test('Test searchCampaigns filters correctly', () async { + final mockFundService = locator(); + final mockCampaigns = [ + Campaign(id: '1', name: 'Summer Campaign'), + Campaign(id: '2', name: 'Winter Campaign'), + ]; + + when(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockCampaigns); + + await model.fetchCampaigns('1'); + model.searchCampaigns('summer'); + + expect(model.filteredCampaigns.length, 1); + expect(model.filteredCampaigns[0].name, 'Summer Campaign'); + }); + + test('Test sortCampaigns changes sort option and refetches', () async { + model.parentFundId = '1'; + final mockFundService = locator(); + + when(mockFundService.getCampaigns('1', orderBy: 'name_ASC')) + .thenAnswer((_) async => []); + + model.sortCampaigns('name_ASC'); + await model.fetchCampaigns('1'); + expect(model.campaignSortOption, 'name_ASC'); + verify(mockFundService.getCampaigns('1', orderBy: 'name_ASC')) + .called(1); + }); + }); + + group('Pledge Tests', () { + test('Test fetchPledges success', () async { + final mockFundService = locator(); + final mockPledges = [ + Pledge( + id: '1', + pledgers: [User(id: userConfig.currentUser.id)], + ), + Pledge( + id: '2', + pledgers: [User(id: 'other_user')], + ), + ]; + + when(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockPledges); + + await model.fetchPledges('1'); + + expect(model.allPledges.length, 2); + expect(model.userPledges.length, 1); + expect(model.isFetchingPledges, false); + verify( + mockFundService.getPledgesByCampaign( + '1', + orderBy: 'endDate_DESC', + ), + ).called(1); + }); + + test('Test createPledge success', () async { + final mockFundService = locator(); + final pledgeData = {'amount': 100, 'campaignId': '1'}; + model.parentcampaignId = '1'; + + when(mockFundService.createPledge(pledgeData)).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql('')), + data: {'createPledge': pledgeData}, + source: QueryResultSource.network, + ), + ); + + await model.createPledge(pledgeData); + + verify(mockFundService.createPledge(pledgeData)).called(1); + verify( + mockFundService.getPledgesByCampaign( + '1', + orderBy: 'endDate_DESC', + ), + ).called(1); + }); + + test('Test updatePledge success', () async { + final mockFundService = locator(); + final pledgeData = {'id': '1', 'amount': 200}; + model.parentcampaignId = '1'; + + when(mockFundService.updatePledge(pledgeData)).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql('')), + data: {'updatePledge': pledgeData}, + source: QueryResultSource.network, + ), + ); + + await model.updatePledge(pledgeData); + + verify(mockFundService.updatePledge(pledgeData)).called(1); + verify( + mockFundService.getPledgesByCampaign( + '1', + orderBy: 'endDate_DESC', + ), + ).called(1); + }); + + test('Test deletePledge success', () async { + final mockFundService = locator(); + model.parentcampaignId = '1'; + + when(mockFundService.deletePledge('1')).thenAnswer( + (_) async => QueryResult( + options: QueryOptions(document: gql('')), + data: { + 'deletePledge': {'id': '1'}, + }, + source: QueryResultSource.network, + ), + ); + + await model.deletePledge('1', '1'); + + verify(mockFundService.deletePledge('1')).called(1); + verify( + mockFundService.getPledgesByCampaign( + '1', + orderBy: 'endDate_DESC', + ), + ).called(1); + }); + + test('Test searchPledgers filters correctly', () async { + final mockFundService = locator(); + final mockPledges = [ + Pledge( + id: '1', + pledgers: [User(id: userConfig.currentUser.id, firstName: 'John')], + ), + Pledge( + id: '2', + pledgers: [User(id: userConfig.currentUser.id, firstName: 'Jane')], + ), + ]; + + when(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockPledges); + + await model.fetchPledges('1'); + model.searchPledgers('john'); + + expect(model.filteredPledges.length, 1); + expect(model.filteredPledges[0].pledgers![0].firstName, 'John'); + }); + }); + + test('Test getCurrentOrgUsersList success', () async { + await model.getCurrentOrgUsersList(); + expect(model.orgMembersList.length, 2); + expect(model.orgMembersList[0].id, "fakeUser1"); + }); + + test('Test selectFund triggers campaign fetch', () async { + final mockFundService = locator(); + + when(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => []); + + model.selectFund('1'); + + verify(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .called(2); + }); + + test('Test selectCampaign triggers pledge fetch', () async { + final mockFundService = locator(); + + when(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => []); + + model.selectCampaign('1'); + + verify(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .called(2); + }); + }); +} diff --git a/test/views/after_auth_screens/funds/fund_pledges_screen_test.dart b/test/views/after_auth_screens/funds/fund_pledges_screen_test.dart new file mode 100644 index 000000000..79e25e3c3 --- /dev/null +++ b/test/views/after_auth_screens/funds/fund_pledges_screen_test.dart @@ -0,0 +1,252 @@ +// 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:mockito/mockito.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/fund_service.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/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/views/after_auth_screens/funds/fund_pledges_screen.dart'; +import 'package:talawa/widgets/pledge_card.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +// Helper function to create test campaign +Campaign getTestCampaign() { + return Campaign( + id: "1", + name: "Test Campaign", + fundingGoal: 1000.0, + startDate: DateTime.now(), + endDate: DateTime.now().add(const Duration(days: 30)), + ); +} + +// Helper function to create test pledges +List getTestPledges() { + return [ + Pledge( + id: "1", + amount: 100, + endDate: DateTime.now().add(const Duration(days: 15)), + pledgers: [User(firstName: 'John', lastName: 'Doe')], + ), + Pledge( + id: "2", + amount: 200, + endDate: DateTime.now().add(const Duration(days: 20)), + pledgers: [User(firstName: 'Jane', lastName: 'Smith')], + ), + ]; +} + +Widget createPledgesScreen() { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: PledgesScreen(campaign: getTestCampaign()), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); +} + +void main() { + testSetupLocator(); + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('PledgesScreen Widget Tests', () { + testWidgets('Check if PledgesScreen shows up', (tester) async { + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + expect(find.byType(PledgesScreen), findsOneWidget); + expect(find.text('Pledges for Test Campaign'), findsOneWidget); + }); + + testWidgets('Test tab buttons functionality', (tester) async { + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Check if both tabs exist + expect(find.text('Pledged'), findsOneWidget); + expect(find.text('Raised'), findsOneWidget); + + // Test tab switching + await tester.tap(find.text('Raised')); + await tester.pumpAndSettle(); + + // Verify color change or selection state + final raisedButton = tester.widget( + find + .ancestor( + of: find.text('Raised'), + matching: find.byType(Container), + ) + .first, + ); + expect(raisedButton.decoration, isA()); + }); + + testWidgets('Test search functionality', (tester) async { + final mockFundViewModel = FundViewModel(); + // Setup mock data + mockFundViewModel.allPledges.addAll(getTestPledges()); + + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Find and interact with search field + final searchField = find.byType(TextField).first; + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'John'); + await tester.pumpAndSettle(); + mockFundViewModel.allPledges.clear(); + }); + + testWidgets('Test sort dropdown functionality', (tester) async { + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Find dropdown + final dropdownButton = find.byType(DropdownButton); + expect(dropdownButton, findsOneWidget); + + // Open dropdown + await tester.tap(dropdownButton); + await tester.pumpAndSettle(); + + // Verify dropdown items + expect(find.text('End Date (Latest)'), findsWidgets); + expect(find.text('Amount (Highest)'), findsOneWidget); + }); + + testWidgets('Test add pledge functionality', (tester) async { + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Find and tap FAB + final fabButton = find.byType(FloatingActionButton); + expect(fabButton, findsOneWidget); + + await tester.tap(fabButton); + await tester.pumpAndSettle(); + + // Verify dialog appears + expect(find.text('Create Pledge'), findsOneWidget); + }); + + testWidgets('Test pledge list view when empty', (tester) async { + final mockFundViewModel = FundViewModel(); + // Setup empty pledges + mockFundViewModel.allPledges.clear(); + + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + expect(find.text('No pledges found.'), findsOneWidget); + }); + + testWidgets('Test progress indicator display', (tester) async { + final mockFundViewModel = FundViewModel(); + // Setup mock data with specific values + mockFundViewModel.allPledges.addAll(getTestPledges()); + + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Verify progress bar elements + expect(find.byType(Stack), findsWidgets); + expect(find.textContaining('%'), findsOneWidget); + expect(find.textContaining('Goal: \$'), findsOneWidget); + }); + + testWidgets('Test pledge card interactions', (tester) async { + final mockFundService = locator(); + final mockPledges = [ + Pledge( + id: '1', + pledgers: [ + User( + id: userConfig.currentUser.id, + firstName: 'John', + lastName: 'Doe', + ), + ], + amount: 100, + ), + ]; + + when(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockPledges); + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Find pledge cards + expect(find.byType(PledgeCard), findsWidgets); + + // Test update pledge + await tester.tap(find.byIcon(Icons.edit).first); + await tester.pumpAndSettle(); + + // Verify update dialog appears + expect(find.text('Update Pledge'), findsOneWidget); + }); + + testWidgets('Test delete pledge confirmation', (tester) async { + final mockFundService = locator(); + final mockPledges = [ + Pledge( + id: '1', + pledgers: [ + User( + id: userConfig.currentUser.id, + firstName: 'John', + lastName: 'Doe', + ), + ], + amount: 100, + ), + ]; + + when(mockFundService.getPledgesByCampaign('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockPledges); + + await tester.pumpWidget(createPledgesScreen()); + await tester.pumpAndSettle(); + + // Find and tap delete button + await tester.tap(find.byIcon(Icons.delete).first); + await tester.pumpAndSettle(); + + // Verify delete confirmation dialog + expect(find.text('Delete Pledge'), findsOneWidget); + expect( + find.text('Are you sure you want to delete this pledge?'), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsWidgets); + }); + }); +} diff --git a/test/views/after_auth_screens/funds/fund_screen_test.dart b/test/views/after_auth_screens/funds/fund_screen_test.dart new file mode 100644 index 000000000..ba43296e1 --- /dev/null +++ b/test/views/after_auth_screens/funds/fund_screen_test.dart @@ -0,0 +1,142 @@ +// 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:mockito/mockito.dart'; +import 'package:talawa/models/funds/fund.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/fund_service.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/views/after_auth_screens/funds/fundraising_campaigns_screen.dart'; +import 'package:talawa/views/after_auth_screens/funds/funds_screen.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +Widget createFundScreen() { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: const FundScreen(), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); +} + +void main() { + testSetupLocator(); + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('FundScreen Widget Tests', () { + testWidgets('Check if FundScreen shows up', (tester) async { + await tester.pumpWidget(createFundScreen()); + await tester.pumpAndSettle(); + + expect(find.byType(FundScreen), findsOneWidget); + expect(find.text('Funds'), findsOneWidget); + }); + + testWidgets('Test search functionality', (tester) async { + final mockFundService = locator(); + final mockFunds = [ + Fund( + id: '1', + name: 'Test Fund', + creator: User(firstName: 'John', lastName: 'Doe'), + ), + Fund( + id: '2', + name: 'Fund 2', + creator: User(firstName: 'John1', lastName: 'Doe2'), + ), + ]; + when(mockFundService.getFunds(orderBy: 'createdAt_DESC')) + .thenAnswer((_) async => mockFunds); + + await tester.pumpWidget(createFundScreen()); + await tester.pumpAndSettle(); + + // Find and interact with search field + final searchField = find.byType(TextField).first; + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'Test'); + await tester.pumpAndSettle(); + + // Verify that the filtered fund is displayed + expect(find.text('Test Fund'), findsOneWidget); + expect(find.text('Fund 2'), findsNothing); + }); + + testWidgets('Test sort dropdown functionality', (tester) async { + await tester.pumpWidget(createFundScreen()); + await tester.pumpAndSettle(); + + // Find dropdown + final dropdownButton = find.byType(DropdownButton); + expect(dropdownButton, findsOneWidget); + + // Open dropdown + await tester.tap(dropdownButton); + await tester.pumpAndSettle(); + + // Verify dropdown items + expect(find.text('Newest'), findsWidgets); + expect(find.text('Oldest'), findsOneWidget); + }); + + testWidgets('Test fund list view when empty', (tester) async { + final mockFundService = locator(); + when(mockFundService.getFunds(orderBy: 'createdAt_DESC')) + .thenAnswer((_) async => []); + await tester.pumpWidget(createFundScreen()); + await tester.pumpAndSettle(); + + expect(find.text('No funds in this organization.'), findsOneWidget); + }); + + testWidgets('Test fund card interactions', (tester) async { + final mockFundService = locator(); + final mockFund = Fund( + id: '1', + name: 'Test Fund', + creator: User(firstName: 'John', lastName: 'Doe'), + createdAt: DateTime.now(), + ); + + when( + mockFundService.getFunds( + orderBy: 'createdAt_DESC', + ), + ).thenAnswer((_) async => [mockFund]); + + await tester.pumpWidget(createFundScreen()); + await tester.pumpAndSettle(); + + // Find fund cards + expect(find.byType(FundCard), findsWidgets); + + // Test navigate to campaigns screen + await tester.tap(find.byIcon(Icons.campaign).first); + await tester.pumpAndSettle(); + + // Verify campaigns screen is displayed + expect(find.byType(CampaignsScreen), findsOneWidget); + }); + }); +} diff --git a/test/views/after_auth_screens/funds/fundraising_campaigns_screen_test.dart b/test/views/after_auth_screens/funds/fundraising_campaigns_screen_test.dart new file mode 100644 index 000000000..d4f8521ed --- /dev/null +++ b/test/views/after_auth_screens/funds/fundraising_campaigns_screen_test.dart @@ -0,0 +1,146 @@ +// 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:mockito/mockito.dart'; +import 'package:talawa/models/funds/fund_campaign.dart'; +import 'package:talawa/router.dart' as router; +import 'package:talawa/services/fund_service.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/views/after_auth_screens/funds/fund_pledges_screen.dart'; +import 'package:talawa/views/after_auth_screens/funds/fundraising_campaigns_screen.dart'; + +import '../../../helpers/test_helpers.dart'; +import '../../../helpers/test_locator.dart'; + +Widget createCampaignsScreen() { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: const CampaignsScreen( + fundId: '1', + fundName: 'Test Fund', + ), + navigatorKey: locator().navigatorKey, + onGenerateRoute: router.generateRoute, + ); +} + +void main() { + testSetupLocator(); + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + + group('CampaignsScreen Widget Tests', () { + testWidgets('Check if CampaignsScreen shows up', (tester) async { + await tester.pumpWidget(createCampaignsScreen()); + await tester.pumpAndSettle(); + + expect(find.byType(CampaignsScreen), findsOneWidget); + expect(find.text('Campaigns'), findsOneWidget); + }); + + testWidgets('Test search functionality', (tester) async { + final mockFundService = locator(); + final mockCampaign = [ + Campaign( + id: '1', + name: 'Test Campaign', + startDate: DateTime.now(), + endDate: DateTime.now().add(const Duration(days: 30)), + fundingGoal: 1000.0, + ), + ]; + when(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => mockCampaign); + + await tester.pumpWidget(createCampaignsScreen()); + await tester.pumpAndSettle(); + + // Find and interact with search field + final searchField = find.byType(TextField).first; + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'Test'); + await tester.pumpAndSettle(); + + // Verify that the filtered campaign is displayed + expect(find.text('Test Campaign'), findsOneWidget); + }); + + testWidgets('Test sort dropdown functionality', (tester) async { + await tester.pumpWidget(createCampaignsScreen()); + await tester.pumpAndSettle(); + + // Find dropdown + final dropdownButton = find.byType(DropdownButton); + expect(dropdownButton, findsOneWidget); + + // Open dropdown + await tester.tap(dropdownButton); + await tester.pumpAndSettle(); + + // Verify dropdown items + expect(find.text('End Date (Latest)'), findsWidgets); + expect(find.text('End Date (Earliest)'), findsOneWidget); + expect(find.text('Amount (Highest)'), findsOneWidget); + expect(find.text('Amount (Lowest)'), findsOneWidget); + }); + + testWidgets('Test campaign list view when empty', (tester) async { + final mockFundService = locator(); + + when(mockFundService.getCampaigns('1', orderBy: 'endDate_DESC')) + .thenAnswer((_) async => []); + + await tester.pumpWidget(createCampaignsScreen()); + await tester.pumpAndSettle(); + + expect(find.text('No campaigns for this fund.'), findsOneWidget); + }); + + testWidgets('Test campaign card interactions', (tester) async { + final mockFundService = locator(); + final mockCampaign = Campaign( + id: '1', + name: 'Test Campaign', + startDate: DateTime.now(), + endDate: DateTime.now().add(const Duration(days: 30)), + fundingGoal: 1000.0, + ); + + when( + mockFundService.getCampaigns( + '1', + orderBy: 'endDate_DESC', + ), + ).thenAnswer((_) async => [mockCampaign]); + + await tester.pumpWidget(createCampaignsScreen()); + await tester.pumpAndSettle(); + + // Find campaign cards + expect(find.byType(CampaignCard), findsWidgets); + + // Test navigate to pledges screen + await tester.tap(find.byIcon(Icons.volunteer_activism).first); + await tester.pumpAndSettle(); + + // Verify pledges screen is displayed + expect(find.byType(PledgesScreen), findsOneWidget); + }); + }); +} diff --git a/test/views/after_auth_screens/profile/profile_page_test.dart b/test/views/after_auth_screens/profile/profile_page_test.dart index 8828231c7..629ec934d 100644 --- a/test/views/after_auth_screens/profile/profile_page_test.dart +++ b/test/views/after_auth_screens/profile/profile_page_test.dart @@ -1,3 +1,4 @@ +// ignore_for_file: talawa_api_doc import 'package:contained_tab_bar_view/contained_tab_bar_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -75,16 +76,6 @@ void main() async { await tester.tap(find.byKey(const Key('inviteicon'))); await tester.pump(); }); - testWidgets('check if left drawer works', (tester) async { - await tester.pumpWidget( - createProfilePage( - mainScreenViewModel: locator(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.menu)); - await tester.pumpAndSettle(); - }); testWidgets('check if Donate button work', (tester) async { await tester.pumpWidget( createProfilePage( diff --git a/test/widget_tests/widgets/add_pledge_dialogue_box_test.dart b/test/widget_tests/widgets/add_pledge_dialogue_box_test.dart new file mode 100644 index 000000000..9acaa63fb --- /dev/null +++ b/test/widget_tests/widgets/add_pledge_dialogue_box_test.dart @@ -0,0 +1,286 @@ +// 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:mockito/mockito.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/widgets/add_pledge_dialogue_box.dart'; + +class MockFundViewModel extends Mock implements FundViewModel { + @override + String get donationCurrency => 'USD'; + + @override + String get donationCurrencySymbol => '\$'; + + @override + List get orgMembersList => [ + User( + id: '1', + firstName: 'John', + lastName: 'Doe', + ), + User( + id: '2', + firstName: 'Jane', + lastName: 'Smith', + ), + User( + id: '3', + firstName: 'Bob', + lastName: 'Johnson', + ), + ]; +} + +Widget createAddPledgeDialog({ + required Function(Map) onSubmit, + required FundViewModel model, + required String campaignId, +}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AddPledgeDialog( + onSubmit: onSubmit, + model: model, + campaignId: campaignId, + ), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ); +} + +void main() { + late MockFundViewModel mockModel; + late Map submittedData; + + setUp(() { + mockModel = MockFundViewModel(); + submittedData = {}; + }); + + group('AddPledgeDialog Widget Tests', () { + testWidgets('Dialog shows up with correct initial state', + (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify initial state + expect(find.text('Create Pledge'), findsOneWidget); + expect(find.text('Select Pledger:'), findsOneWidget); + expect(find.text('Select Start date'), findsOneWidget); + expect(find.text('Select End date'), findsOneWidget); + expect(find.text('Amount'), findsOneWidget); + expect(find.text('USD'), findsOneWidget); + }); + + testWidgets('Can select pledgers from popup menu', + (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Open pledger selection menu + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + // Select a pledger + await tester.tap(find.text('John Doe').last); + await tester.pumpAndSettle(); + + // Verify pledger chip appears + expect(find.byType(Chip), findsOneWidget); + expect(find.text('John Doe'), findsOneWidget); + }); + + testWidgets('Can remove selected pledgers', (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Add a pledger + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + await tester.tap(find.text('John Doe').last); + await tester.pumpAndSettle(); + + // Remove the pledger + await tester.tap(find.byIcon(Icons.cancel)); + await tester.pumpAndSettle(); + + // Verify pledger is removed + expect(find.byType(Chip), findsNothing); + }); + + testWidgets('Can select dates', (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Select start date + await tester.tap(find.text('Select Start date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Verify start date is selected + expect(find.text('Select Start date'), findsNothing); + expect(find.textContaining('Start:'), findsOneWidget); + + // Select end date + await tester.tap(find.text('Select End date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Verify end date is selected + expect(find.text('Select End date'), findsNothing); + expect(find.textContaining('End:'), findsOneWidget); + }); + + testWidgets('Form validation works correctly', (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Try to submit empty form + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + // Verify error message + expect(find.text('Please fill all fields'), findsOneWidget); + }); + + testWidgets('Can submit valid form', (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Fill form + // Add pledger + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + await tester.tap(find.text('John Doe').last); + await tester.pumpAndSettle(); + + // Enter amount + await tester.enterText(find.byType(TextFormField), '100'); + + // Select dates + await tester.tap(find.text('Select Start date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select End date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Submit form + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + // Verify form submission + expect(submittedData.isEmpty, false); + expect(submittedData['campaignId'], '123'); + expect(submittedData['amount'], 100.0); + expect(submittedData['userIds'], ['1']); + expect(submittedData['currency'], 'USD'); + }); + + testWidgets('Can cancel dialog', (WidgetTester tester) async { + await tester.pumpWidget( + createAddPledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + campaignId: '123', + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Cancel dialog + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Verify dialog is closed + expect(find.text('Create Pledge'), findsNothing); + }); + }); +} diff --git a/test/widget_tests/widgets/pledge_card_test.dart b/test/widget_tests/widgets/pledge_card_test.dart new file mode 100644 index 000000000..e06cfc8b5 --- /dev/null +++ b/test/widget_tests/widgets/pledge_card_test.dart @@ -0,0 +1,179 @@ +// 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:intl/intl.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/services/size_config.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/widgets/pledge_card.dart'; + +import '../../helpers/test_helpers.dart'; +import '../../helpers/test_locator.dart'; + +Widget createPledgeCard({ + required Pledge pledge, + VoidCallback? onUpdate, + VoidCallback? onDelete, +}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: PledgeCard( + pledge: pledge, + onUpdate: onUpdate ?? () {}, + onDelete: onDelete ?? () {}, + ), + ), + ); +} + +final mockPledge = Pledge( + id: '1', + amount: 1000, + startDate: DateTime(2024, 1, 1), + endDate: DateTime(2024, 12, 31), + pledgers: [ + User( + id: '1', + firstName: 'John', + lastName: 'Doe', + ), + User( + id: '2', + firstName: 'Jane', + lastName: 'Smith', + ), + User( + id: '3', + firstName: 'Bob', + lastName: 'Johnson', + ), + User( + id: '4', + firstName: 'Alice', + lastName: 'Brown', + ), + ], +); + +void main() { + testSetupLocator(); + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerServices(); + locator().test(); + }); + + tearDownAll(() { + unregisterServices(); + }); + group('PledgeCard Widget Tests', () { + testWidgets('PledgeCard displays basic information correctly', + (WidgetTester tester) async { + await tester.pumpWidget(createPledgeCard(pledge: mockPledge)); + await tester.pumpAndSettle(); + + // Verify header + expect(find.text('Pledge Group'), findsOneWidget); + + // Verify amount information + expect(find.text('Pledged'), findsOneWidget); + expect(find.text('\$1000.00'), findsOneWidget); + expect(find.text('Donated'), findsOneWidget); + expect(find.text('\$0.00'), findsOneWidget); + + // Verify dates + expect(find.text('Start Date'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + expect( + find.text(DateFormat('MMM d, y').format(DateTime(2024, 1, 1))), + findsOneWidget, + ); + expect( + find.text(DateFormat('MMM d, y').format(DateTime(2024, 12, 31))), + findsOneWidget, + ); + }); + + testWidgets('PledgeCard displays correct number of pledgers', + (WidgetTester tester) async { + await tester.pumpWidget(createPledgeCard(pledge: mockPledge)); + await tester.pumpAndSettle(); + + // Verify pledgers section + expect(find.text('Pledgers'), findsOneWidget); + + // Should show first 3 pledgers + expect(find.text('John Doe'), findsOneWidget); + expect(find.text('Jane Smith'), findsOneWidget); + expect(find.text('Bob Johnson'), findsOneWidget); + + // Should show +1 more chip for the remaining pledger + expect(find.text('+1 more'), findsOneWidget); + }); + + testWidgets('PledgeCard handles null dates correctly', + (WidgetTester tester) async { + final pledgeWithNullDates = Pledge( + id: '1', + amount: 1000, + startDate: null, + endDate: null, + pledgers: [], + ); + + await tester.pumpWidget(createPledgeCard(pledge: pledgeWithNullDates)); + await tester.pumpAndSettle(); + + expect(find.text('N/A'), findsNWidgets(2)); + }); + + testWidgets('PledgeCard handles empty pledgers list', + (WidgetTester tester) async { + final pledgeWithNoPledgers = Pledge( + id: '1', + amount: 1000, + startDate: DateTime.now(), + endDate: DateTime.now(), + pledgers: [], + ); + + await tester.pumpWidget(createPledgeCard(pledge: pledgeWithNoPledgers)); + await tester.pumpAndSettle(); + + expect(find.byType(Chip), findsNothing); + }); + + testWidgets('Update and Delete buttons trigger callbacks', + (WidgetTester tester) async { + bool updatePressed = false; + bool deletePressed = false; + + await tester.pumpWidget( + createPledgeCard( + pledge: mockPledge, + onUpdate: () => updatePressed = true, + onDelete: () => deletePressed = true, + ), + ); + await tester.pumpAndSettle(); + + // Test Update button + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(updatePressed, true); + + // Test Delete button + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(deletePressed, true); + }); + }); +} diff --git a/test/widget_tests/widgets/update_pledge_dialogue_box_test.dart b/test/widget_tests/widgets/update_pledge_dialogue_box_test.dart new file mode 100644 index 000000000..be3c364d4 --- /dev/null +++ b/test/widget_tests/widgets/update_pledge_dialogue_box_test.dart @@ -0,0 +1,455 @@ +// 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:mockito/mockito.dart'; +import 'package:talawa/models/funds/fund_pledges.dart'; +import 'package:talawa/models/user/user_info.dart'; +import 'package:talawa/utils/app_localization.dart'; +import 'package:talawa/view_model/after_auth_view_models/funds_view_models/fund_view_model.dart'; +import 'package:talawa/widgets/update_pledge_dialogue_box.dart'; + +class MockFundViewModel extends Mock implements FundViewModel { + @override + String get donationCurrency => 'USD'; + + @override + String get donationCurrencySymbol => '\$'; + + @override + List get orgMembersList => [ + User( + id: '1', + firstName: 'John', + lastName: 'Doe', + ), + User( + id: '2', + firstName: 'Jane', + lastName: 'Smith', + ), + User( + id: '3', + firstName: 'Bob', + lastName: 'Johnson', + ), + ]; +} + +Widget createUpdatePledgeDialog({ + required Function(Map) onSubmit, + required FundViewModel model, + required Pledge pledge, +}) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: [ + const AppLocalizationsDelegate(isTest: true), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => UpdatePledgeDialog( + onSubmit: onSubmit, + model: model, + pledge: pledge, + ), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ); +} + +void main() { + late MockFundViewModel mockModel; + late Map submittedData; + late Pledge testPledge; + + setUp(() { + mockModel = MockFundViewModel(); + submittedData = {}; + testPledge = Pledge( + id: '123', + amount: 100, + startDate: DateTime(2024, 1, 1), + endDate: DateTime(2024, 12, 31), + pledgers: [ + User( + id: '1', + firstName: 'John', + lastName: 'Doe', + ), + ], + ); + }); + + group('UpdatePledgeDialog Widget Tests', () { + testWidgets('Dialog shows up with correct initial state', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify initial state + expect(find.text('Update Pledge'), findsOneWidget); + expect(find.text('Select Pledger:'), findsOneWidget); + expect(find.text('100'), findsOneWidget); + expect(find.text('USD'), findsOneWidget); + expect(find.text('John Doe'), findsOneWidget); + expect(find.textContaining('Start: Jan 1, 2024'), findsOneWidget); + expect(find.textContaining('End: Dec 31, 2024'), findsOneWidget); + }); + + testWidgets('Can add new pledgers from popup menu', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Open pledger selection menu + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + // Select a new pledger + await tester.tap(find.text('Jane Smith').last); + await tester.pumpAndSettle(); + + // Verify both pledgers are shown + expect(find.text('John Doe'), findsOneWidget); + expect(find.text('Jane Smith'), findsOneWidget); + expect(find.byType(Chip), findsNWidgets(2)); + }); + + testWidgets('Can remove pledgers', (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Remove existing pledger + await tester.tap(find.byIcon(Icons.cancel)); + await tester.pumpAndSettle(); + + // Verify pledger is removed + expect(find.byType(Chip), findsNothing); + }); + + testWidgets('Can update dates', (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Update start date + await tester.tap(find.textContaining('Start:')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Update end date + await tester.tap(find.textContaining('End:')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Verify dates are updated + expect(find.textContaining('Start:'), findsOneWidget); + expect(find.textContaining('End:'), findsOneWidget); + }); + + testWidgets('Update button is disabled when no changes are made', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify update button is disabled + final updateButton = find.text('Update'); + expect( + tester + .widget( + find.ancestor( + of: updateButton, + matching: find.byType(ElevatedButton), + ), + ) + .onPressed, + null, + ); + }); + + testWidgets('Update button is enabled when changes are made', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify initial state - update button should be disabled + final initialUpdateButton = tester.widget( + find.byKey(const Key('update_btn')), + ); + expect(initialUpdateButton.onPressed, null); + + // Change amount + await tester.enterText(find.byKey(const Key('amount_field')), '200'); + await tester.pumpAndSettle(); + + // Verify initial state - update button should be disabled + final updateButton = tester.widget( + find.byKey(const Key('update_btn')), + ); + expect(updateButton.onPressed, isNotNull); + }); + + testWidgets('Can submit valid form with updates', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Update amount + await tester.enterText(find.byType(TextFormField), '200'); + await tester.pumpAndSettle(); + + // Add new pledger + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Jane Smith').last); + await tester.pumpAndSettle(); + + // Submit form + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + // Verify form submission + expect(submittedData.isEmpty, false); + expect(submittedData['id'], '123'); + expect(submittedData['amount'], 200.0); + expect(submittedData['users'], ['1', '2']); + }); + + testWidgets('Can cancel dialog', (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Cancel dialog + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Verify dialog is closed + expect(find.text('Update Pledge'), findsNothing); + }); + + testWidgets('Shows error message when form is invalid', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Remove all pledgers + await tester.tap(find.byIcon(Icons.cancel)); + await tester.pumpAndSettle(); + + // Clear amount + await tester.enterText(find.byType(TextFormField), ''); + await tester.pumpAndSettle(); + + // Try to submit + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + // Verify error message + expect(find.text('Please fill all fields'), findsOneWidget); + }); + group('_getChangedFields Tests', () { + testWidgets('Correctly identifies changed amount', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Change amount + await tester.enterText(find.byKey(const Key('amount_field')), '200'); + await tester.pumpAndSettle(); + + // Submit form + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + // Verify changed fields + expect(submittedData['id'], '123'); + expect(submittedData['amount'], 200.0); + expect(submittedData.containsKey('currency'), false); + expect(submittedData.containsKey('startDate'), false); + expect(submittedData.containsKey('endDate'), false); + expect(submittedData.containsKey('users'), false); + }); + testWidgets('Correctly identifies changed pledgers', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Add new pledger + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Jane Smith').last); + await tester.pumpAndSettle(); + + // Submit form + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + // Verify changed fields include users + expect(submittedData['id'], '123'); + expect(submittedData['users'], ['1', '2']); + expect(submittedData.containsKey('amount'), false); + expect(submittedData.containsKey('currency'), false); + expect(submittedData.containsKey('startDate'), false); + expect(submittedData.containsKey('endDate'), false); + }); + + testWidgets('Correctly identifies multiple changes', + (WidgetTester tester) async { + await tester.pumpWidget( + createUpdatePledgeDialog( + onSubmit: (data) => submittedData = data, + model: mockModel, + pledge: testPledge, + ), + ); + await tester.pumpAndSettle(); + + // Open dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Change amount + await tester.enterText(find.byKey(const Key('amount_field')), '200'); + await tester.pumpAndSettle(); + + // Add new pledger + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Jane Smith').last); + await tester.pumpAndSettle(); + + // Submit form + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + + // Verify all changed fields are included + expect(submittedData['id'], '123'); + expect(submittedData['amount'], 200.0); + expect(submittedData['users'], ['1', '2']); + expect(submittedData.containsKey('currency'), false); + }); + }); + }); +}