diff --git a/data/lib/api/support/support_models.dart b/data/lib/api/support/support_models.dart new file mode 100644 index 00000000..33ab8c1b --- /dev/null +++ b/data/lib/api/support/support_models.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'support_models.freezed.dart'; + +part 'support_models.g.dart'; + +@freezed +class AddSupportCaseRequest with _$AddSupportCaseRequest { + const factory AddSupportCaseRequest({ + String? id, + required String title, + String? description, + @Default([]) List attachmentUrls, + required String userId, + required DateTime createdAt, + }) = _AddSupportCaseRequest; + + factory AddSupportCaseRequest.fromJson(Map json) => + _$AddSupportCaseRequestFromJson(json); +} + +enum AttachmentUploadStatus { + uploading, + uploaded; + + bool get isUploading => this == uploading; + + bool get isUploaded => this == uploaded; +} + +@freezed +class Attachment with _$Attachment { + const factory Attachment({ + required String path, + String? url, + @Default('') String name, + @Default(AttachmentUploadStatus.uploading) + AttachmentUploadStatus uploadStatus, + }) = _Attachment; +} diff --git a/data/lib/api/support/support_models.freezed.dart b/data/lib/api/support/support_models.freezed.dart new file mode 100644 index 00000000..5382f67f --- /dev/null +++ b/data/lib/api/support/support_models.freezed.dart @@ -0,0 +1,456 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'support_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +AddSupportCaseRequest _$AddSupportCaseRequestFromJson( + Map json) { + return _AddSupportCaseRequest.fromJson(json); +} + +/// @nodoc +mixin _$AddSupportCaseRequest { + String? get id => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + List get attachmentUrls => throw _privateConstructorUsedError; + String get userId => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $AddSupportCaseRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AddSupportCaseRequestCopyWith<$Res> { + factory $AddSupportCaseRequestCopyWith(AddSupportCaseRequest value, + $Res Function(AddSupportCaseRequest) then) = + _$AddSupportCaseRequestCopyWithImpl<$Res, AddSupportCaseRequest>; + @useResult + $Res call( + {String? id, + String title, + String? description, + List attachmentUrls, + String userId, + DateTime createdAt}); +} + +/// @nodoc +class _$AddSupportCaseRequestCopyWithImpl<$Res, + $Val extends AddSupportCaseRequest> + implements $AddSupportCaseRequestCopyWith<$Res> { + _$AddSupportCaseRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? title = null, + Object? description = freezed, + Object? attachmentUrls = null, + Object? userId = null, + Object? createdAt = null, + }) { + return _then(_value.copyWith( + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + attachmentUrls: null == attachmentUrls + ? _value.attachmentUrls + : attachmentUrls // ignore: cast_nullable_to_non_nullable + as List, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AddSupportCaseRequestImplCopyWith<$Res> + implements $AddSupportCaseRequestCopyWith<$Res> { + factory _$$AddSupportCaseRequestImplCopyWith( + _$AddSupportCaseRequestImpl value, + $Res Function(_$AddSupportCaseRequestImpl) then) = + __$$AddSupportCaseRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String? id, + String title, + String? description, + List attachmentUrls, + String userId, + DateTime createdAt}); +} + +/// @nodoc +class __$$AddSupportCaseRequestImplCopyWithImpl<$Res> + extends _$AddSupportCaseRequestCopyWithImpl<$Res, + _$AddSupportCaseRequestImpl> + implements _$$AddSupportCaseRequestImplCopyWith<$Res> { + __$$AddSupportCaseRequestImplCopyWithImpl(_$AddSupportCaseRequestImpl _value, + $Res Function(_$AddSupportCaseRequestImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? title = null, + Object? description = freezed, + Object? attachmentUrls = null, + Object? userId = null, + Object? createdAt = null, + }) { + return _then(_$AddSupportCaseRequestImpl( + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + attachmentUrls: null == attachmentUrls + ? _value._attachmentUrls + : attachmentUrls // ignore: cast_nullable_to_non_nullable + as List, + userId: null == userId + ? _value.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$AddSupportCaseRequestImpl implements _AddSupportCaseRequest { + const _$AddSupportCaseRequestImpl( + {this.id, + required this.title, + this.description, + final List attachmentUrls = const [], + required this.userId, + required this.createdAt}) + : _attachmentUrls = attachmentUrls; + + factory _$AddSupportCaseRequestImpl.fromJson(Map json) => + _$$AddSupportCaseRequestImplFromJson(json); + + @override + final String? id; + @override + final String title; + @override + final String? description; + final List _attachmentUrls; + @override + @JsonKey() + List get attachmentUrls { + if (_attachmentUrls is EqualUnmodifiableListView) return _attachmentUrls; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_attachmentUrls); + } + + @override + final String userId; + @override + final DateTime createdAt; + + @override + String toString() { + return 'AddSupportCaseRequest(id: $id, title: $title, description: $description, attachmentUrls: $attachmentUrls, userId: $userId, createdAt: $createdAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AddSupportCaseRequestImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.title, title) || other.title == title) && + (identical(other.description, description) || + other.description == description) && + const DeepCollectionEquality() + .equals(other._attachmentUrls, _attachmentUrls) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, id, title, description, + const DeepCollectionEquality().hash(_attachmentUrls), userId, createdAt); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AddSupportCaseRequestImplCopyWith<_$AddSupportCaseRequestImpl> + get copyWith => __$$AddSupportCaseRequestImplCopyWithImpl< + _$AddSupportCaseRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$AddSupportCaseRequestImplToJson( + this, + ); + } +} + +abstract class _AddSupportCaseRequest implements AddSupportCaseRequest { + const factory _AddSupportCaseRequest( + {final String? id, + required final String title, + final String? description, + final List attachmentUrls, + required final String userId, + required final DateTime createdAt}) = _$AddSupportCaseRequestImpl; + + factory _AddSupportCaseRequest.fromJson(Map json) = + _$AddSupportCaseRequestImpl.fromJson; + + @override + String? get id; + @override + String get title; + @override + String? get description; + @override + List get attachmentUrls; + @override + String get userId; + @override + DateTime get createdAt; + @override + @JsonKey(ignore: true) + _$$AddSupportCaseRequestImplCopyWith<_$AddSupportCaseRequestImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$Attachment { + String get path => throw _privateConstructorUsedError; + String? get url => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + AttachmentUploadStatus get uploadStatus => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $AttachmentCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AttachmentCopyWith<$Res> { + factory $AttachmentCopyWith( + Attachment value, $Res Function(Attachment) then) = + _$AttachmentCopyWithImpl<$Res, Attachment>; + @useResult + $Res call( + {String path, + String? url, + String name, + AttachmentUploadStatus uploadStatus}); +} + +/// @nodoc +class _$AttachmentCopyWithImpl<$Res, $Val extends Attachment> + implements $AttachmentCopyWith<$Res> { + _$AttachmentCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? url = freezed, + Object? name = null, + Object? uploadStatus = null, + }) { + return _then(_value.copyWith( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + url: freezed == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String?, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uploadStatus: null == uploadStatus + ? _value.uploadStatus + : uploadStatus // ignore: cast_nullable_to_non_nullable + as AttachmentUploadStatus, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AttachmentImplCopyWith<$Res> + implements $AttachmentCopyWith<$Res> { + factory _$$AttachmentImplCopyWith( + _$AttachmentImpl value, $Res Function(_$AttachmentImpl) then) = + __$$AttachmentImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String path, + String? url, + String name, + AttachmentUploadStatus uploadStatus}); +} + +/// @nodoc +class __$$AttachmentImplCopyWithImpl<$Res> + extends _$AttachmentCopyWithImpl<$Res, _$AttachmentImpl> + implements _$$AttachmentImplCopyWith<$Res> { + __$$AttachmentImplCopyWithImpl( + _$AttachmentImpl _value, $Res Function(_$AttachmentImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? url = freezed, + Object? name = null, + Object? uploadStatus = null, + }) { + return _then(_$AttachmentImpl( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + url: freezed == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String?, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uploadStatus: null == uploadStatus + ? _value.uploadStatus + : uploadStatus // ignore: cast_nullable_to_non_nullable + as AttachmentUploadStatus, + )); + } +} + +/// @nodoc + +class _$AttachmentImpl implements _Attachment { + const _$AttachmentImpl( + {required this.path, + this.url, + this.name = '', + this.uploadStatus = AttachmentUploadStatus.uploading}); + + @override + final String path; + @override + final String? url; + @override + @JsonKey() + final String name; + @override + @JsonKey() + final AttachmentUploadStatus uploadStatus; + + @override + String toString() { + return 'Attachment(path: $path, url: $url, name: $name, uploadStatus: $uploadStatus)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AttachmentImpl && + (identical(other.path, path) || other.path == path) && + (identical(other.url, url) || other.url == url) && + (identical(other.name, name) || other.name == name) && + (identical(other.uploadStatus, uploadStatus) || + other.uploadStatus == uploadStatus)); + } + + @override + int get hashCode => Object.hash(runtimeType, path, url, name, uploadStatus); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AttachmentImplCopyWith<_$AttachmentImpl> get copyWith => + __$$AttachmentImplCopyWithImpl<_$AttachmentImpl>(this, _$identity); +} + +abstract class _Attachment implements Attachment { + const factory _Attachment( + {required final String path, + final String? url, + final String name, + final AttachmentUploadStatus uploadStatus}) = _$AttachmentImpl; + + @override + String get path; + @override + String? get url; + @override + String get name; + @override + AttachmentUploadStatus get uploadStatus; + @override + @JsonKey(ignore: true) + _$$AttachmentImplCopyWith<_$AttachmentImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/data/lib/api/support/support_models.g.dart b/data/lib/api/support/support_models.g.dart new file mode 100644 index 00000000..24198f75 --- /dev/null +++ b/data/lib/api/support/support_models.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'support_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AddSupportCaseRequestImpl _$$AddSupportCaseRequestImplFromJson( + Map json) => + _$AddSupportCaseRequestImpl( + id: json['id'] as String?, + title: json['title'] as String, + description: json['description'] as String?, + attachmentUrls: (json['attachmentUrls'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + userId: json['userId'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$$AddSupportCaseRequestImplToJson( + _$AddSupportCaseRequestImpl instance) => + { + 'id': instance.id, + 'title': instance.title, + 'description': instance.description, + 'attachmentUrls': instance.attachmentUrls, + 'userId': instance.userId, + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/data/lib/errors/app_error.dart b/data/lib/errors/app_error.dart index f9cd7cdb..897b7bbb 100644 --- a/data/lib/errors/app_error.dart +++ b/data/lib/errors/app_error.dart @@ -32,6 +32,8 @@ class AppError implements Exception { return _handleFirebaseError(error); } else if (error is TypeError) { return SomethingWentWrongError(stackTrace: error.stackTrace); + } else if (error is LargeAttachmentUploadError) { + return const LargeAttachmentUploadError(); } else { return SomethingWentWrongError(stackTrace: stack); } @@ -83,3 +85,11 @@ class SomethingWentWrongError extends AppError { super.stackTrace, }) : super(l10nCode: AppErrorL10nCodes.somethingWentWrongError); } + +class LargeAttachmentUploadError extends AppError { + const LargeAttachmentUploadError() + : super( + l10nCode: AppErrorL10nCodes.largeAttachmentUpload, + message: + "Oops! Your file exceeds the maximum allowed size of 25 MB. Please choose a smaller file and try again."); +} diff --git a/data/lib/errors/app_error_l10n_codes.dart b/data/lib/errors/app_error_l10n_codes.dart index 0204aa2e..b12f7fc2 100644 --- a/data/lib/errors/app_error_l10n_codes.dart +++ b/data/lib/errors/app_error_l10n_codes.dart @@ -4,4 +4,5 @@ class AppErrorL10nCodes { static const invalidVerificationCode = 'invalid-verification-code'; static const invalidPhoneNumber = 'invalid-phone-number'; static const tooManyRequests = 'too-many-requests'; + static const largeAttachmentUpload = "large-attachment-upload"; } diff --git a/data/lib/service/file_upload/file_upload_service.dart b/data/lib/service/file_upload/file_upload_service.dart index f4546724..d76e4912 100644 --- a/data/lib/service/file_upload/file_upload_service.dart +++ b/data/lib/service/file_upload/file_upload_service.dart @@ -50,7 +50,8 @@ class FileUploadService { enum ImageUploadType { user(FireStoreConst.userProfileImagesFolder), - team(FireStoreConst.teamProfileImagesFolder); + team(FireStoreConst.teamProfileImagesFolder), + support(FireStoreConst.supportImagesFolder); const ImageUploadType(this.value); diff --git a/data/lib/service/support/support_service.dart b/data/lib/service/support/support_service.dart new file mode 100644 index 00000000..c04c3530 --- /dev/null +++ b/data/lib/service/support/support_service.dart @@ -0,0 +1,36 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:data/api/support/support_models.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../errors/app_error.dart'; +import '../../utils/constant/firestore_constant.dart'; + +final supportServiceProvider = Provider( + (ref) => SupportService(FirebaseFirestore.instance), +); + +class SupportService { + final FirebaseFirestore _firestore; + + SupportService(this._firestore); + + Future addSupportCase(AddSupportCaseRequest supportCase) async { + try { + DocumentReference supportCaseRef = _firestore + .collection(FireStoreConst.supportCollection) + .doc(supportCase.id); + WriteBatch batch = _firestore.batch(); + + batch.set(supportCaseRef, supportCase.toJson(), SetOptions(merge: true)); + String newSupportCaseId = supportCaseRef.id; + + if (supportCase.id == null) { + batch.update(supportCaseRef, {FireStoreConst.id: newSupportCaseId}); + } + await batch.commit(); + return newSupportCaseId; + } catch (error, stack) { + throw AppError.fromError(error, stack); + } + } +} diff --git a/data/lib/utils/constant/firestore_constant.dart b/data/lib/utils/constant/firestore_constant.dart index 9b371658..42cad65a 100644 --- a/data/lib/utils/constant/firestore_constant.dart +++ b/data/lib/utils/constant/firestore_constant.dart @@ -5,8 +5,10 @@ class FireStoreConst { static const String inningsCollection = "innings"; static const String ballScoresCollection = "ball_scores"; static const String usersCollection = "users"; + static const String supportCollection = "contact_support"; static const String userProfileImagesFolder = "UserProfileImages"; static const String teamProfileImagesFolder = "TeamProfileImages"; + static const String supportImagesFolder = "SupportImages"; // matches field const static const String id = "id"; diff --git a/khelo/assets/locales/app_en.arb b/khelo/assets/locales/app_en.arb index 91c90f42..d4036383 100644 --- a/khelo/assets/locales/app_en.arb +++ b/khelo/assets/locales/app_en.arb @@ -650,6 +650,10 @@ "@_CONTACT_SUPPORT": { }, "contact_support_title": "Contact support", + "contact_support_title_placeholder_text": "Title", + "contact_support_description_placeholder_text": "Description...", + "contact_support_attachment": "Attachment (if any)", + "large_attachment_upload_error_text": "Oops! Your file exceeds the maximum allowed size of 25 MB. Please choose a smaller file and try again.", "profile_complete_profile_btn_title": "Complete Profile", "profile_complete_your_profile_title": "Complete your profile", diff --git a/khelo/lib/domain/extensions/app_error_extension.dart b/khelo/lib/domain/extensions/app_error_extension.dart index 6941c7c9..4e2ec03e 100644 --- a/khelo/lib/domain/extensions/app_error_extension.dart +++ b/khelo/lib/domain/extensions/app_error_extension.dart @@ -13,6 +13,8 @@ extension AppErrorExtensions on Object { return context.l10n.error_something_went_wrong; case AppErrorL10nCodes.invalidPhoneNumber: return context.l10n.sign_in_invalid_phone_number_text; + case AppErrorL10nCodes.largeAttachmentUpload: + return context.l10n.large_attachment_upload_error_text; default: return (this as AppError).message ?? context.l10n.error_something_went_wrong; diff --git a/khelo/lib/domain/extensions/string_extensions.dart b/khelo/lib/domain/extensions/string_extensions.dart index 66cb7ad2..b57a49e3 100644 --- a/khelo/lib/domain/extensions/string_extensions.dart +++ b/khelo/lib/domain/extensions/string_extensions.dart @@ -18,4 +18,51 @@ extension EmailValidator on String { return result; } } + + // String URL extensions + String get attachmentName => split('/').last; + + String get attachmentExtension => split('.').last; + + AttachmentsType get attachmentType { + final ext = attachmentExtension.toLowerCase(); + if (ext == 'jpg' || ext == 'jpeg' || ext == 'png') { + return AttachmentsType.image; + } else if (ext == 'txt') { + return AttachmentsType.text; + } else if (ext == 'pdf') { + return AttachmentsType.pdf; + } else if (ext == 'gif') { + return AttachmentsType.gif; + } else if (ext == 'mp3' || ext == 'aac' || ext == 'wav' || ext == 'm4a') { + return AttachmentsType.audio; + } else if (ext == 'mp4' || ext == 'mov' || ext == 'webm' || ext == 'mkv') { + return AttachmentsType.video; + } + return AttachmentsType.other; + } +} + +enum AttachmentsType { + image, + video, + gif, + audio, + pdf, + text, + other; + + bool get isImage => this == image; + + bool get isVideo => this == video; + + bool get isGif => this == gif; + + bool get isAudio => this == audio; + + bool get isPdf => this == pdf; + + bool get isText => this == text; + + bool get isOther => this == other; } diff --git a/khelo/lib/ui/app_route.dart b/khelo/lib/ui/app_route.dart index 40cc5de6..8326e3d4 100644 --- a/khelo/lib/ui/app_route.dart +++ b/khelo/lib/ui/app_route.dart @@ -20,11 +20,13 @@ import 'package:khelo/ui/flow/team/add_team_member/add_team_member_screen.dart'; import 'package:khelo/ui/flow/team/detail/team_detail_screen.dart'; import 'package:khelo/ui/flow/team/search_team/search_team_screen.dart'; import 'flow/main/main_screen.dart'; +import 'flow/settings/support/contact_support_screen.dart'; import 'flow/sign_in/sign_in_with_phone/sign_in_with_phone_screen.dart'; class AppRoute { static const pathPhoneNumberVerification = '/phone-number-verification'; static const pathEditProfile = '/edit-profile'; + static const pathContactSupport = "/contact-support"; static const pathAddTeamMember = '/add-team-member'; static const pathAddTeam = '/add-team'; static const pathPowerPlay = '/power-play'; @@ -216,6 +218,11 @@ class AppRoute { pathEditProfile, builder: (_) => EditProfileScreen(isToCreateAccount: isToCreateAccount)); + static AppRoute contactSupport() => AppRoute( + pathContactSupport, + builder: (_) => const ContactSupportScreen(), + ); + static AppRoute teamDetail({required String teamId}) => AppRoute(pathTeamDetail, builder: (_) => TeamDetailScreen(teamId: teamId)); @@ -243,6 +250,14 @@ class AppRoute { : state.widget(context); }, ), + GoRoute( + path: pathContactSupport, + builder: (context, state) { + return state.extra == null + ? const ContactSupportScreen() + : state.widget(context); + }, + ), phoneLogin.goRoute(), GoRoute( path: pathScoreBoard, diff --git a/khelo/lib/ui/flow/profile/profile_screen.dart b/khelo/lib/ui/flow/profile/profile_screen.dart index 5d2b3a3a..dc94b772 100644 --- a/khelo/lib/ui/flow/profile/profile_screen.dart +++ b/khelo/lib/ui/flow/profile/profile_screen.dart @@ -150,14 +150,14 @@ class ProfileScreen extends ConsumerWidget { context, icon: Assets.images.icContactSupport, title: context.l10n.contact_support_title, - onTap: () {}, + onTap: () =>AppRoute.contactSupport().push(context), ), _settingItem( context, icon: Assets.images.icPrivacyPolicy, title: context.l10n.profile_setting_privacy_policy_title, onTap: () => notifier - .openUrl("https://github.com/canopas/khelo/docs/index.md"), + .openUrl("https://canopas.github.io/khelo/privacy-policy"), ), _settingItem( context, diff --git a/khelo/lib/ui/flow/settings/support/components/attachment_item.dart b/khelo/lib/ui/flow/settings/support/components/attachment_item.dart new file mode 100644 index 00000000..5d28f37f --- /dev/null +++ b/khelo/lib/ui/flow/settings/support/components/attachment_item.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:khelo/domain/extensions/string_extensions.dart'; +import 'package:style/button/action_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/indicator/progress_indicator.dart'; +import 'package:style/text/app_text_style.dart'; + +class AttachmentsTypeView extends StatelessWidget { + final String path; + final bool isNetwork; + + const AttachmentsTypeView({ + super.key, + required this.path, + this.isNetwork = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 50, + width: 50, + decoration: BoxDecoration( + color: context.colorScheme.containerNormal, + borderRadius: BorderRadius.circular(12), + ), + child: _getFileView( + iconColor: context.colorScheme.textDisabled, + path: path, + ), + ); + } + + Widget _getFileView({ + required Color iconColor, + required String path, + }) { + if (path.attachmentType.isImage) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + image: DecorationImage( + image: isNetwork + ? CachedNetworkImageProvider(path) as ImageProvider + : FileImage(File(path)), + fit: BoxFit.cover, + ), + ), + ); + } else if (path.attachmentType.isAudio) { + return Icon(CupertinoIcons.music_note_2, color: iconColor, size: 28); + } else if (path.attachmentType.isVideo) { + return Icon(CupertinoIcons.play_arrow_solid, color: iconColor, size: 28); + } else if (path.attachmentType.isGif) { + return Icon(CupertinoIcons.photo, color: iconColor, size: 28); + } else { + return Icon(CupertinoIcons.doc_fill, color: iconColor, size: 28); + } + } +} + +class AttachmentItem extends StatelessWidget { + final void Function() onCancelTap; + final String path; + final bool isLoading; + + const AttachmentItem({ + super.key, + required this.path, + required this.onCancelTap, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + AttachmentsTypeView(path: path), + const SizedBox(width: 16), + Expanded( + child: Text( + path.attachmentName, + style: AppTextStyle.body2.copyWith( + color: context.colorScheme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isLoading) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AppProgressIndicator(size: AppProgressIndicatorSize.small), + ), + ] else ...[ + actionButton( + context, + onPressed: onCancelTap, + icon: Icon( + Icons.cancel_rounded, + color: context.colorScheme.textDisabled, + size: 28, + ), + ), + ], + ], + ); + } +} diff --git a/khelo/lib/ui/flow/settings/support/contact_support_screen.dart b/khelo/lib/ui/flow/settings/support/contact_support_screen.dart new file mode 100644 index 00000000..70518937 --- /dev/null +++ b/khelo/lib/ui/flow/settings/support/contact_support_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:khelo/components/app_page.dart'; +import 'package:khelo/components/error_snackbar.dart'; +import 'package:khelo/domain/extensions/context_extensions.dart'; +import 'package:khelo/ui/flow/settings/support/contact_support_view_model.dart'; +import 'package:style/animations/on_tap_scale.dart'; +import 'package:style/button/primary_button.dart'; +import 'package:style/extensions/context_extensions.dart'; +import 'package:style/text/app_text_field.dart'; +import 'package:style/text/app_text_style.dart'; + +import 'components/attachment_item.dart'; + +class ContactSupportScreen extends ConsumerStatefulWidget { + const ContactSupportScreen({super.key}); + + @override + ConsumerState createState() => + _ContactSupportScreenState(); +} + +class _ContactSupportScreenState extends ConsumerState { + late ContactSupportViewStateNotifier notifier; + + @override + void initState() { + super.initState(); + notifier = ref.read(contactSupportStateNotifierProvider.notifier); + } + + void _errorObserve() { + ref.listen( + contactSupportStateNotifierProvider.select( + (value) => value.actionError, + ), + (previous, next) { + if (next != null) { + showErrorSnackBar(context: context, error: next); + } + }, + ); + } + + void _popObserve() { + ref.listen( + contactSupportStateNotifierProvider.select( + (value) => value.pop, + ), + (previous, next) { + if (next) { + context.pop(); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(contactSupportStateNotifierProvider); + _errorObserve(); + _popObserve(); + return PopScope( + onPopInvoked: (didPop) { + if (!state.pop) return notifier.discardAttachments(); + }, + child: AppPage( + title: context.l10n.contact_support_title, + body: Builder(builder: (context) { + return _body(context, state); + }), + ), + ); + } + + Widget _body(BuildContext context, ContactSupportViewState state) { + return ListView( + padding: context.mediaQueryPadding + const EdgeInsets.all(16), + children: [ + _textInputField( + context, + placeholderText: context.l10n.contact_support_title_placeholder_text, + controller: state.titleController, + onChanged: (value) => notifier.onValueChange(), + ), + const SizedBox(height: 16), + _textInputField( + context, + placeholderText: + context.l10n.contact_support_description_placeholder_text, + controller: state.descriptionController, + maxLines: 8, + ), + const SizedBox(height: 16), + _attachmentButton( + context: context, + onAttachmentTap: notifier.pickAttachments, + ), + ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.attachments.length, + itemBuilder: (context, index) => AttachmentItem( + path: state.attachments[index].path, + isLoading: state.attachments[index].uploadStatus.isUploading, + onCancelTap: () => notifier.removeAttachment(index), + ), + separatorBuilder: (context, index) => const SizedBox(height: 8), + ), + const SizedBox(height: 16), + PrimaryButton( + enabled: !state.submitting && state.enableSubmit, + progress: state.submitting, + context.l10n.common_submit_title, + onPressed: notifier.submitSupportCase, + ), + ], + ); + } + + Widget _textInputField( + BuildContext context, { + required String placeholderText, + required TextEditingController controller, + Function(String)? onChanged, + int? maxLines, + }) { + return AppTextField( + controller: controller, + onChanged: onChanged, + maxLines: maxLines, + style: AppTextStyle.subtitle3 + .copyWith(color: context.colorScheme.textPrimary), + borderRadius: BorderRadius.circular(12), + borderType: AppTextFieldBorderType.outline, + backgroundColor: context.colorScheme.containerLow, + borderColor: BorderColor( + focusColor: Colors.transparent, unFocusColor: Colors.transparent), + hintText: placeholderText, + hintStyle: AppTextStyle.subtitle3 + .copyWith(color: context.colorScheme.textDisabled), + ); + } + + Widget _attachmentButton({ + required BuildContext context, + required VoidCallback onAttachmentTap, + }) { + return OnTapScale( + onTap: onAttachmentTap, + child: Row( + children: [ + Icon( + CupertinoIcons.paperclip, + color: context.colorScheme.textPrimary, + size: 16, + ), + Text( + context.l10n.contact_support_attachment, + style: AppTextStyle.body2.copyWith( + color: context.colorScheme.textPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/khelo/lib/ui/flow/settings/support/contact_support_view_model.dart b/khelo/lib/ui/flow/settings/support/contact_support_view_model.dart new file mode 100644 index 00000000..e28f4ca9 --- /dev/null +++ b/khelo/lib/ui/flow/settings/support/contact_support_view_model.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:data/errors/app_error.dart'; +import 'package:data/extensions/list_extensions.dart'; +import 'package:data/service/file_upload/file_upload_service.dart'; +import 'package:data/service/support/support_service.dart'; +import 'package:data/storage/app_preferences.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:data/api/support/support_models.dart'; + +part 'contact_support_view_model.freezed.dart'; + +final contactSupportStateNotifierProvider = StateNotifierProvider.autoDispose< + ContactSupportViewStateNotifier, ContactSupportViewState>((ref) { + return ContactSupportViewStateNotifier( + ImagePicker(), + ref.read(fileUploadServiceProvider), + ref.read(supportServiceProvider), + ref.read(currentUserPod)?.id); +}); + +class ContactSupportViewStateNotifier + extends StateNotifier { + final ImagePicker picker; + final FileUploadService fileUploadService; + final SupportService supportService; + final String? _currentUserId; + + ContactSupportViewStateNotifier( + this.picker, + this.fileUploadService, + this.supportService, + this._currentUserId, + ) : super(ContactSupportViewState( + titleController: TextEditingController(), + descriptionController: TextEditingController(), + )); + + void onValueChange() { + state = state.copyWith( + actionError: null, + enableSubmit: state.titleController.text.trim().isNotEmpty && + state.attachments + .where((element) => element.uploadStatus.isUploading) + .firstOrNull == + null, + ); + } + + void _uploadAttachments({required String path, required String name}) async { + try { + state = state.copyWith(attachments: [ + ...state.attachments, + Attachment( + path: path, + name: name, + uploadStatus: AttachmentUploadStatus.uploading), + ]); + onValueChange(); + final bool isLarge = await File(path).length() > 25 * 1024 * 1024; + + if (isLarge) { + final attachments = state.attachments.toList() + ..removeWhere((element) => element.path == path); + + state = state.copyWith( + attachments: attachments, + actionError: const LargeAttachmentUploadError()); + } else { + final url = await fileUploadService.uploadProfileImage( + path, ImageUploadType.support); + state = state.copyWith( + attachments: state.attachments.updateWhere( + where: (attachment) => attachment.path == path, + updated: (oldElement) => oldElement.copyWith( + url: url, + uploadStatus: AttachmentUploadStatus.uploaded, + ), + ), + ); + } + + onValueChange(); + } catch (error, _) { + state = state.copyWith(actionError: error); + debugPrint( + "ContactSupportViewStateNotifier: Error while uploading $path error $error"); + } + } + + void pickAttachments() async { + try { + final List medias = await picker.pickMultipleMedia( + imageQuality: 70, + ); + for (XFile media in medias) { + _uploadAttachments(path: media.path, name: media.name); + } + } catch (error, _) { + debugPrint( + "ContactSupportViewStateNotifier: Error while pick image! $error", + ); + } + } + + void removeAttachment(int index) async { + if (state.attachments.elementAt(index).url != null) { + await fileUploadService + .deleteUploadedImage(state.attachments.elementAt(index).url ?? ''); + } + state = state.copyWith( + attachments: state.attachments.toList()..removeAt(index)); + onValueChange(); + } + + void discardAttachments() async { + for (final attachment in state.attachments) { + if (attachment.url != null) { + await fileUploadService.deleteUploadedImage(attachment.url!); + } + } + state = state.copyWith(attachments: []); + } + + void submitSupportCase() async { + try { + state = state.copyWith(submitting: true, actionError: null); + + final supportCase = AddSupportCaseRequest( + title: state.titleController.text.trim(), + description: state.descriptionController.text.trim(), + attachmentUrls: + state.attachments.map((e) => e.url).whereNotNull().toList(), + userId: _currentUserId ?? '', + createdAt: DateTime.now()); + + await supportService.addSupportCase(supportCase).whenComplete( + () => state = state.copyWith(pop: true, submitting: false), + ); + } catch (error) { + state = state.copyWith(actionError: error, pop: false); + debugPrint( + "ContactSupportViewStateNotifier: Error while adding support case $error"); + } + } +} + +@freezed +class ContactSupportViewState with _$ContactSupportViewState { + const factory ContactSupportViewState({ + Object? actionError, + @Default(false) bool submitting, + @Default(false) bool enableSubmit, + @Default(false) bool pop, + required TextEditingController titleController, + required TextEditingController descriptionController, + @Default([]) List attachments, + }) = _ContactSupportViewState; +} diff --git a/khelo/lib/ui/flow/settings/support/contact_support_view_model.freezed.dart b/khelo/lib/ui/flow/settings/support/contact_support_view_model.freezed.dart new file mode 100644 index 00000000..627f4b65 --- /dev/null +++ b/khelo/lib/ui/flow/settings/support/contact_support_view_model.freezed.dart @@ -0,0 +1,281 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'contact_support_view_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$ContactSupportViewState { + Object? get actionError => throw _privateConstructorUsedError; + bool get submitting => throw _privateConstructorUsedError; + bool get enableSubmit => throw _privateConstructorUsedError; + bool get pop => throw _privateConstructorUsedError; + TextEditingController get titleController => + throw _privateConstructorUsedError; + TextEditingController get descriptionController => + throw _privateConstructorUsedError; + List get attachments => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $ContactSupportViewStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ContactSupportViewStateCopyWith<$Res> { + factory $ContactSupportViewStateCopyWith(ContactSupportViewState value, + $Res Function(ContactSupportViewState) then) = + _$ContactSupportViewStateCopyWithImpl<$Res, ContactSupportViewState>; + @useResult + $Res call( + {Object? actionError, + bool submitting, + bool enableSubmit, + bool pop, + TextEditingController titleController, + TextEditingController descriptionController, + List attachments}); +} + +/// @nodoc +class _$ContactSupportViewStateCopyWithImpl<$Res, + $Val extends ContactSupportViewState> + implements $ContactSupportViewStateCopyWith<$Res> { + _$ContactSupportViewStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? actionError = freezed, + Object? submitting = null, + Object? enableSubmit = null, + Object? pop = null, + Object? titleController = null, + Object? descriptionController = null, + Object? attachments = null, + }) { + return _then(_value.copyWith( + actionError: freezed == actionError ? _value.actionError : actionError, + submitting: null == submitting + ? _value.submitting + : submitting // ignore: cast_nullable_to_non_nullable + as bool, + enableSubmit: null == enableSubmit + ? _value.enableSubmit + : enableSubmit // ignore: cast_nullable_to_non_nullable + as bool, + pop: null == pop + ? _value.pop + : pop // ignore: cast_nullable_to_non_nullable + as bool, + titleController: null == titleController + ? _value.titleController + : titleController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + descriptionController: null == descriptionController + ? _value.descriptionController + : descriptionController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + attachments: null == attachments + ? _value.attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ContactSupportViewStateImplCopyWith<$Res> + implements $ContactSupportViewStateCopyWith<$Res> { + factory _$$ContactSupportViewStateImplCopyWith( + _$ContactSupportViewStateImpl value, + $Res Function(_$ContactSupportViewStateImpl) then) = + __$$ContactSupportViewStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Object? actionError, + bool submitting, + bool enableSubmit, + bool pop, + TextEditingController titleController, + TextEditingController descriptionController, + List attachments}); +} + +/// @nodoc +class __$$ContactSupportViewStateImplCopyWithImpl<$Res> + extends _$ContactSupportViewStateCopyWithImpl<$Res, + _$ContactSupportViewStateImpl> + implements _$$ContactSupportViewStateImplCopyWith<$Res> { + __$$ContactSupportViewStateImplCopyWithImpl( + _$ContactSupportViewStateImpl _value, + $Res Function(_$ContactSupportViewStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? actionError = freezed, + Object? submitting = null, + Object? enableSubmit = null, + Object? pop = null, + Object? titleController = null, + Object? descriptionController = null, + Object? attachments = null, + }) { + return _then(_$ContactSupportViewStateImpl( + actionError: freezed == actionError ? _value.actionError : actionError, + submitting: null == submitting + ? _value.submitting + : submitting // ignore: cast_nullable_to_non_nullable + as bool, + enableSubmit: null == enableSubmit + ? _value.enableSubmit + : enableSubmit // ignore: cast_nullable_to_non_nullable + as bool, + pop: null == pop + ? _value.pop + : pop // ignore: cast_nullable_to_non_nullable + as bool, + titleController: null == titleController + ? _value.titleController + : titleController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + descriptionController: null == descriptionController + ? _value.descriptionController + : descriptionController // ignore: cast_nullable_to_non_nullable + as TextEditingController, + attachments: null == attachments + ? _value._attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$ContactSupportViewStateImpl implements _ContactSupportViewState { + const _$ContactSupportViewStateImpl( + {this.actionError, + this.submitting = false, + this.enableSubmit = false, + this.pop = false, + required this.titleController, + required this.descriptionController, + final List attachments = const []}) + : _attachments = attachments; + + @override + final Object? actionError; + @override + @JsonKey() + final bool submitting; + @override + @JsonKey() + final bool enableSubmit; + @override + @JsonKey() + final bool pop; + @override + final TextEditingController titleController; + @override + final TextEditingController descriptionController; + final List _attachments; + @override + @JsonKey() + List get attachments { + if (_attachments is EqualUnmodifiableListView) return _attachments; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_attachments); + } + + @override + String toString() { + return 'ContactSupportViewState(actionError: $actionError, submitting: $submitting, enableSubmit: $enableSubmit, pop: $pop, titleController: $titleController, descriptionController: $descriptionController, attachments: $attachments)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ContactSupportViewStateImpl && + const DeepCollectionEquality() + .equals(other.actionError, actionError) && + (identical(other.submitting, submitting) || + other.submitting == submitting) && + (identical(other.enableSubmit, enableSubmit) || + other.enableSubmit == enableSubmit) && + (identical(other.pop, pop) || other.pop == pop) && + (identical(other.titleController, titleController) || + other.titleController == titleController) && + (identical(other.descriptionController, descriptionController) || + other.descriptionController == descriptionController) && + const DeepCollectionEquality() + .equals(other._attachments, _attachments)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(actionError), + submitting, + enableSubmit, + pop, + titleController, + descriptionController, + const DeepCollectionEquality().hash(_attachments)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$ContactSupportViewStateImplCopyWith<_$ContactSupportViewStateImpl> + get copyWith => __$$ContactSupportViewStateImplCopyWithImpl< + _$ContactSupportViewStateImpl>(this, _$identity); +} + +abstract class _ContactSupportViewState implements ContactSupportViewState { + const factory _ContactSupportViewState( + {final Object? actionError, + final bool submitting, + final bool enableSubmit, + final bool pop, + required final TextEditingController titleController, + required final TextEditingController descriptionController, + final List attachments}) = _$ContactSupportViewStateImpl; + + @override + Object? get actionError; + @override + bool get submitting; + @override + bool get enableSubmit; + @override + bool get pop; + @override + TextEditingController get titleController; + @override + TextEditingController get descriptionController; + @override + List get attachments; + @override + @JsonKey(ignore: true) + _$$ContactSupportViewStateImplCopyWith<_$ContactSupportViewStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/khelo/pubspec.lock b/khelo/pubspec.lock index adc66fe6..8e2c2dba 100644 --- a/khelo/pubspec.lock +++ b/khelo/pubspec.lock @@ -234,7 +234,7 @@ packages: source: hosted version: "4.10.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a diff --git a/khelo/pubspec.yaml b/khelo/pubspec.yaml index 7a4f47fd..576b6972 100644 --- a/khelo/pubspec.yaml +++ b/khelo/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: shared_preferences: ^2.2.2 rxdart: ^0.27.7 + collection: any + # UI cupertino_icons: ^1.0.2 image_cropper: ^7.0.3