diff --git a/app/assets/images/ic_apps.svg b/app/assets/images/ic_apps.svg new file mode 100644 index 000000000..318db96a4 --- /dev/null +++ b/app/assets/images/ic_apps.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/ic_chart2.svg b/app/assets/images/ic_chart2.svg new file mode 100644 index 000000000..a295e715e --- /dev/null +++ b/app/assets/images/ic_chart2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/images/ic_money.svg b/app/assets/images/ic_money.svg new file mode 100644 index 000000000..0dfaca64e --- /dev/null +++ b/app/assets/images/ic_money.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/ic_users.svg b/app/assets/images/ic_users.svg new file mode 100644 index 000000000..c8b7aad25 --- /dev/null +++ b/app/assets/images/ic_users.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/lib/backend/http/api/apps.dart b/app/lib/backend/http/api/apps.dart index 146609d82..c2b207133 100644 --- a/app/lib/backend/http/api/apps.dart +++ b/app/lib/backend/http/api/apps.dart @@ -143,10 +143,8 @@ Future isAppSetupCompleted(String? url) async { headers: {}, body: '', ); - var data; try { - data = jsonDecode(response?.body ?? '{}'); - print(data); + var data = jsonDecode(response?.body ?? '{}'); return data['is_setup_completed'] ?? false; } on FormatException catch (e) { debugPrint('Response not a valid json: $e'); @@ -160,7 +158,7 @@ Future isAppSetupCompleted(String? url) async { Future submitAppServer(File file, Map appData) async { var request = http.MultipartRequest( 'POST', - Uri.parse('${Env.apiBaseUrl}v1/apps'), + Uri.parse('${Env.apiBaseUrl}v2/apps'), ); request.files.add(await http.MultipartFile.fromPath('file', file.path, filename: basename(file.path))); request.headers.addAll({'Authorization': await getAuthHeader()}); diff --git a/app/lib/backend/http/api/users.dart b/app/lib/backend/http/api/users.dart index fc05f14c3..fa36e9334 100644 --- a/app/lib/backend/http/api/users.dart +++ b/app/lib/backend/http/api/users.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:friend_private/backend/http/shared.dart'; import 'package:friend_private/backend/schema/geolocation.dart'; import 'package:friend_private/backend/schema/person.dart'; +import 'package:friend_private/backend/schema/profile.dart'; import 'package:friend_private/env/env.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; @@ -280,3 +281,109 @@ Future getHasConversationSummaryRating(String conversationId) async { return false; } } + +Future getCreatorProfile() async { + try { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/users/creator-profile', + headers: {}, + method: 'GET', + body: '', + ); + print(response?.body); + if (response == null) return null; + debugPrint('getCreatorProfile response: ${response.body}'); + if (response.statusCode == 200) { + Map json = jsonDecode(response.body); + if (json.isEmpty) { + return CreatorProfile.empty(); + } + return CreatorProfile.fromJson(json); + } + return null; + } catch (e) { + debugPrint('getCreatorProfile error: $e'); + return null; + } +} + +Future saveCreatorProfile(CreatorProfile profile) async { + try { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/users/creator-profile', + headers: {}, + method: 'POST', + body: jsonEncode(profile.toJson()), + ); + if (response == null) return false; + debugPrint('saveCreatorProfile response: ${response.body}'); + return response.statusCode == 200; + } catch (e) { + debugPrint('saveCreatorProfile error: $e'); + return false; + } +} + +Future updateCreatorProfileServer(String? name, String? email, String? paypalEmail, String? paypalLink) async { + try { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/users/creator-profile', + headers: {}, + method: 'PATCH', + body: jsonEncode({ + 'creator_name': name, + 'creator_email': email, + 'paypal_details': { + 'paypal_email': paypalEmail, + 'paypal_me_link': paypalLink, + }, + }), + ); + if (response == null) return false; + debugPrint('updateCreatorProfile response: ${response.body}'); + return response.statusCode == 200; + } catch (e) { + debugPrint('updateCreatorProfile error: $e'); + return false; + } +} + +Future getCreatorStatsServer() async { + try { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/users/creator-stats', + headers: {}, + method: 'GET', + body: '', + ); + if (response == null) return null; + debugPrint('getCreatorStatsServer response: ${response.body}'); + if (response.statusCode == 200) { + return CreatorStats.fromJson(jsonDecode(response.body)); + } + return null; + } catch (e) { + debugPrint('getCreatorStatsServer error: $e'); + return null; + } +} + +Future getPayoutHistoryServer() async { + try { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/users/payout-history', + headers: {}, + method: 'GET', + body: '', + ); + if (response == null) return null; + debugPrint('getPayoutHistoryServer response: ${response.body}'); + if (response.statusCode == 200) { + return PayoutTransaction.fromJsonList(jsonDecode(response.body)); + } + return null; + } catch (e) { + debugPrint('getPayoutHistoryServer error: $e'); + return null; + } +} diff --git a/app/lib/backend/schema/profile.dart b/app/lib/backend/schema/profile.dart new file mode 100644 index 000000000..897bed68b --- /dev/null +++ b/app/lib/backend/schema/profile.dart @@ -0,0 +1,168 @@ +class PayPalDetails { + final String email; + final String? paypalMeLink; + + PayPalDetails({ + required this.email, + this.paypalMeLink, + }); + + factory PayPalDetails.fromJson(Map json) { + return PayPalDetails( + email: json['paypal_email'], + paypalMeLink: json['paypal_me_link'] ?? '', + ); + } + + Map toJson() { + return { + 'paypal_email': email, + 'paypal_me_link': paypalMeLink ?? '', + }; + } +} + +class CreatorProfile { + final String creatorName; + final String creatorEmail; + final PayPalDetails paypalDetails; + final bool? isVerified; + + CreatorProfile({ + required this.creatorName, + required this.creatorEmail, + required this.paypalDetails, + this.isVerified, + }); + + factory CreatorProfile.fromJson(Map json) { + return CreatorProfile( + creatorName: json['creator_name'], + creatorEmail: json['creator_email'], + paypalDetails: PayPalDetails.fromJson(json['paypal_details']), + isVerified: json['is_verified'] ?? false, + ); + } + + Map toJson() { + return { + 'creator_name': creatorName, + 'creator_email': creatorEmail, + 'paypal_details': paypalDetails.toJson(), + 'is_verified': isVerified ?? false, + }; + } + + bool isEmpty() { + return creatorName.isEmpty && creatorEmail.isEmpty && paypalDetails.email.isEmpty; + } + + static CreatorProfile empty() { + return CreatorProfile( + creatorName: '', + creatorEmail: '', + paypalDetails: PayPalDetails(email: ''), + isVerified: false, + ); + } +} + +class CreatorStats { + final int usageCount; + final double moneyMade; + final int appsCount; + final int activeUsers; + + CreatorStats({ + required this.usageCount, + required this.moneyMade, + required this.appsCount, + required this.activeUsers, + }); + + factory CreatorStats.fromJson(Map json) { + var usageCount = json['usage_count'] as Map; + var totalUsage = usageCount.values.fold(0, (prev, element) => (prev + element).toInt()); + + var moneyMade = json['money_made'] as Map; + var totalMoneyMade = moneyMade.values.fold(0.0, (prev, element) => (prev + element)); + var activeUsers = json['active_users'] as Map; + var totalActiveUsers = activeUsers.values.fold(0, (prev, element) => (prev + element).toInt()); + + return CreatorStats( + usageCount: totalUsage, + moneyMade: totalMoneyMade, + appsCount: json['apps_count'].length, + activeUsers: totalActiveUsers, + ); + } +} + +class PayoutTransaction { + final String amount; + final String currency; + final DateTime date; + final String paymentStatus; + final String payoutMethod; + + PayoutTransaction({ + required this.amount, + required this.date, + required this.paymentStatus, + required this.payoutMethod, + required this.currency, + }); + + factory PayoutTransaction.fromJson(Map json) { + return PayoutTransaction( + amount: json['amount']['value'], + currency: json['amount']['currency_code'], + date: DateTime.parse(json['payment_date']).toLocal(), + paymentStatus: json['payment_status'], + payoutMethod: json['payee']['payment_method'], + ); + } + + PayoutTransaction.empty() + : amount = '', + currency = '', + date = DateTime.now(), + paymentStatus = '', + payoutMethod = ''; + + bool isPending() { + return paymentStatus == 'pending'; + } + + bool isSuccessful() { + return paymentStatus == 'successful'; + } + + bool isFailed() { + return paymentStatus == 'failed'; + } + + String paymentStatusText() { + if (isPending()) { + return 'Pending'; + } else if (isSuccessful()) { + return 'Successful'; + } else if (isFailed()) { + return 'Failed'; + } else { + return 'Unknown'; + } + } + + String payoutMethodText() { + if (payoutMethod == 'paypal') { + return 'PayPal'; + } else { + return 'Unknown'; + } + } + + static List fromJsonList(List jsonList) { + return jsonList.map((e) => PayoutTransaction.fromJson(e)).toList(); + } +} diff --git a/app/lib/gen/assets.gen.dart b/app/lib/gen/assets.gen.dart index 4fbd4f9e4..a50f74ad1 100644 --- a/app/lib/gen/assets.gen.dart +++ b/app/lib/gen/assets.gen.dart @@ -97,6 +97,9 @@ class $AssetsImagesGen { AssetGenImage get herologo => const AssetGenImage('assets/images/herologo.png'); + /// File path: assets/images/ic_apps.svg + String get icApps => 'assets/images/ic_apps.svg'; + /// File path: assets/images/herologo_v1.png AssetGenImage get herologoV1 => const AssetGenImage('assets/images/herologo_v1.png'); @@ -112,9 +115,18 @@ class $AssetsImagesGen { /// File path: assets/images/ic_chart.svg String get icChart => 'assets/images/ic_chart.svg'; + /// File path: assets/images/ic_chart2.svg + String get icChart2 => 'assets/images/ic_chart2.svg'; + /// File path: assets/images/ic_dollar.svg String get icDollar => 'assets/images/ic_dollar.svg'; + /// File path: assets/images/ic_money.svg + String get icMoney => 'assets/images/ic_money.svg'; + + /// File path: assets/images/ic_users.svg + String get icUsers => 'assets/images/ic_users.svg'; + /// File path: assets/images/instruction_1.png AssetGenImage get instruction1 => const AssetGenImage('assets/images/instruction_1.png'); @@ -187,11 +199,15 @@ class $AssetsImagesGen { blob, emotionalFeedback1, herologo, + icApps, herologoV1, herologoV3, herologoV4, icChart, + icChart2, icDollar, + icMoney, + icUsers, instruction1, instruction2, instruction3, diff --git a/app/lib/main.dart b/app/lib/main.dart index b0614cc8a..a83607646 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -22,6 +22,7 @@ import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/home/page.dart'; import 'package:friend_private/pages/conversation_detail/conversation_detail_provider.dart'; import 'package:friend_private/pages/onboarding/wrapper.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_profile_provider.dart'; import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/providers/auth_provider.dart'; import 'package:friend_private/providers/calendar_provider.dart'; @@ -203,6 +204,7 @@ class _MyAppState extends State with WidgetsBindingObserver { update: (BuildContext context, value, AddAppProvider? previous) => (previous?..setAppProvider(value)) ?? AddAppProvider(), ), + ChangeNotifierProvider(create: (context) => CreatorProfileProvider()), ], builder: (context, child) { return WithForegroundTask( diff --git a/app/lib/pages/apps/add_app.dart b/app/lib/pages/apps/add_app.dart index 3ed05cb61..620b5d70a 100644 --- a/app/lib/pages/apps/add_app.dart +++ b/app/lib/pages/apps/add_app.dart @@ -109,8 +109,6 @@ class _AddAppPageState extends State { appPricing: provider.isPaid ? 'Paid' : 'Free', appNameController: provider.appNameController, appDescriptionController: provider.appDescriptionController, - creatorNameController: provider.creatorNameController, - creatorEmailController: provider.creatorEmailController, categories: provider.categories, setAppCategory: provider.setAppCategory, imageFile: provider.imageFile, diff --git a/app/lib/pages/apps/explore_install_page.dart b/app/lib/pages/apps/explore_install_page.dart index 75ff4a9f5..d31916b24 100644 --- a/app/lib/pages/apps/explore_install_page.dart +++ b/app/lib/pages/apps/explore_install_page.dart @@ -5,12 +5,16 @@ import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/apps/widgets/app_section_card.dart'; import 'package:friend_private/pages/apps/widgets/filter_sheet.dart'; import 'package:friend_private/pages/apps/list_item.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_profile_provider.dart'; import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/providers/home_provider.dart'; import 'package:friend_private/utils/analytics/mixpanel.dart'; import 'package:friend_private/utils/other/temp.dart'; +import 'package:friend_private/widgets/dialog.dart'; import 'package:provider/provider.dart'; +import '../settings/creator_profile/creator_profile_details.dart'; + String filterValueToString(dynamic value) { if (value.runtimeType == String) { return value; @@ -191,7 +195,24 @@ class _ExploreInstallPageState extends State with AutomaticK child: GestureDetector( onTap: () { MixpanelManager().pageOpened('Submit App'); - routeToPage(context, const AddAppPage()); + if (context.read().profileExists) { + routeToPage(context, const AddAppPage()); + } else { + showDialog( + context: context, + builder: (c) => getDialog( + context, + () => Navigator.pop(context), + () async { + Navigator.pop(context); + routeToPage(context, const CreatorProfileDetails()); + }, + 'App Creator Program', + "\nJoin Omi App Creator Program to submit apps and receive payments", + okButtonText: 'Join Now', + ), + ); + } }, child: Container( padding: const EdgeInsets.all(12.0), diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 9083eac0d..a80ae4b0b 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -23,8 +23,6 @@ class AddAppProvider extends ChangeNotifier { TextEditingController appNameController = TextEditingController(); TextEditingController appDescriptionController = TextEditingController(); - TextEditingController creatorNameController = TextEditingController(); - TextEditingController creatorEmailController = TextEditingController(); TextEditingController chatPromptController = TextEditingController(); TextEditingController conversationPromptController = TextEditingController(); String? appCategory; @@ -79,8 +77,6 @@ class AddAppProvider extends ChangeNotifier { if (paymentPlans.isEmpty) { await getPaymentPlans(); } - creatorNameController.text = SharedPreferencesUtil().givenName; - creatorEmailController.text = SharedPreferencesUtil().email; setIsLoading(false); } @@ -127,9 +123,7 @@ class AddAppProvider extends ChangeNotifier { imageUrl = app.image; appNameController.text = app.name.decodeString; appDescriptionController.text = app.description.decodeString; - creatorNameController.text = app.author.decodeString; priceController.text = app.price.toString(); - creatorEmailController.text = app.email ?? ''; makeAppPublic = !app.private; selectedCapabilities = app.getCapabilitiesFromIds(capabilities); if (app.externalIntegration != null) { @@ -159,8 +153,6 @@ class AddAppProvider extends ChangeNotifier { void clear() { appNameController.clear(); appDescriptionController.clear(); - creatorNameController.clear(); - creatorEmailController.clear(); chatPromptController.clear(); conversationPromptController.clear(); triggerEvent = null; @@ -243,9 +235,6 @@ class AddAppProvider extends ChangeNotifier { if (appDescriptionController.text != app.description) { return true; } - if (creatorNameController.text != app.author) { - return true; - } if (makeAppPublic != !app.private) { return true; } @@ -410,8 +399,6 @@ class AddAppProvider extends ChangeNotifier { Map data = { 'name': appNameController.text, 'description': appDescriptionController.text, - 'author': creatorNameController.text, - 'email': creatorEmailController.text, 'capabilities': selectedCapabilities.map((e) => e.id).toList(), 'deleted': false, 'uid': SharedPreferencesUtil().uid, @@ -471,8 +458,6 @@ class AddAppProvider extends ChangeNotifier { Map data = { 'name': appNameController.text, 'description': appDescriptionController.text, - 'author': creatorNameController.text, - 'email': creatorEmailController.text, 'capabilities': selectedCapabilities.map((e) => e.id).toList(), 'deleted': false, 'uid': SharedPreferencesUtil().uid, diff --git a/app/lib/pages/apps/update_app.dart b/app/lib/pages/apps/update_app.dart index a125299b0..113f5e858 100644 --- a/app/lib/pages/apps/update_app.dart +++ b/app/lib/pages/apps/update_app.dart @@ -111,8 +111,6 @@ class _UpdateAppPageState extends State { imageFile: provider.imageFile, appNameController: provider.appNameController, appDescriptionController: provider.appDescriptionController, - creatorNameController: provider.creatorNameController, - creatorEmailController: provider.creatorEmailController, categories: provider.categories, setAppCategory: provider.setAppCategory, imageUrl: provider.imageUrl, diff --git a/app/lib/pages/apps/widgets/app_metadata_widget.dart b/app/lib/pages/apps/widgets/app_metadata_widget.dart index 2ee71e650..d0be437eb 100644 --- a/app/lib/pages/apps/widgets/app_metadata_widget.dart +++ b/app/lib/pages/apps/widgets/app_metadata_widget.dart @@ -15,8 +15,6 @@ class AppMetadataWidget extends StatelessWidget { final VoidCallback pickImage; final TextEditingController appNameController; final TextEditingController appDescriptionController; - final TextEditingController creatorNameController; - final TextEditingController creatorEmailController; final List categories; final Function(String?) setAppCategory; final String? category; @@ -31,8 +29,6 @@ class AppMetadataWidget extends StatelessWidget { required this.pickImage, required this.appNameController, required this.appDescriptionController, - required this.creatorNameController, - required this.creatorEmailController, required this.categories, required this.setAppCategory, this.category, @@ -270,75 +266,6 @@ class AppMetadataWidget extends StatelessWidget { const SizedBox( height: 16, ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - 'Creator Name', - style: TextStyle(color: Colors.grey.shade300, fontSize: 16), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), - decoration: BoxDecoration( - color: Colors.grey.shade800, - borderRadius: BorderRadius.circular(10.0), - ), - width: double.infinity, - child: TextFormField( - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter creator name'; - } - return null; - }, - controller: creatorNameController, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only(top: 6, bottom: 6), - isDense: true, - errorText: null, - border: InputBorder.none, - hintText: 'Nik Shevchenko', - ), - ), - ), - const SizedBox( - height: 16, - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - 'Email Address', - style: TextStyle(color: Colors.grey.shade300, fontSize: 16), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), - decoration: BoxDecoration( - color: Colors.grey.shade800, - borderRadius: BorderRadius.circular(10.0), - ), - width: double.infinity, - child: TextFormField( - controller: creatorEmailController, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter creator email'; - } - return null; - }, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only(top: 6, bottom: 6), - isDense: true, - border: InputBorder.none, - hintText: 'nik@basedhardware.com', - ), - ), - ), - const SizedBox( - height: 16, - ), Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( diff --git a/app/lib/pages/settings/creator_profile/creator_dashboard.dart b/app/lib/pages/settings/creator_profile/creator_dashboard.dart new file mode 100644 index 000000000..7ad1b75a5 --- /dev/null +++ b/app/lib/pages/settings/creator_profile/creator_dashboard.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:friend_private/gen/assets.gen.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_profile_details.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_profile_provider.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +import 'payout_history.dart'; + +class CreatorDashboard extends StatefulWidget { + const CreatorDashboard({super.key}); + + @override + State createState() => _CreatorDashboardState(); +} + +class _CreatorDashboardState extends State { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await Provider.of(context, listen: false).getCreatorProfileDetails(); + await Provider.of(context, listen: false).getCreatorStats(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.primary, + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.primary, + elevation: 0, + title: const Text('Creator Dashboard'), + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + body: Consumer(builder: (context, provider, child) { + return SingleChildScrollView( + child: Skeletonizer( + enabled: provider.isLoading, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + Skeleton.shade(child: SvgPicture.asset(Assets.images.icMoney, width: 38)), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("\$${provider.totalEarnings}", + style: const TextStyle(color: Colors.white, fontSize: 20)), + const SizedBox(height: 4), + const Text("Money Earned", style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + Skeleton.shade(child: SvgPicture.asset(Assets.images.icChart2, width: 38)), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(provider.totalUsage.toString(), + style: const TextStyle(color: Colors.white, fontSize: 20)), + const SizedBox(height: 4), + const Text("Times Used", style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + width: MediaQuery.sizeOf(context).width * 0.46, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + Skeleton.shade(child: SvgPicture.asset(Assets.images.icApps, width: 36)), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(provider.publishedApps.toString(), + style: const TextStyle(color: Colors.white, fontSize: 18)), + const SizedBox(height: 4), + const Text("Published Apps", style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + width: MediaQuery.sizeOf(context).width * 0.46, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + Skeleton.shade(child: SvgPicture.asset(Assets.images.icUsers, width: 36)), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(provider.totalUsers.toString(), + style: const TextStyle(color: Colors.white, fontSize: 18)), + const SizedBox(height: 4), + const Text("Active Users", style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ], + ), + ), + ], + ), + InkWell( + onTap: () { + routeToPage(context, const CreatorProfileDetails()); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + const Text("Creator Profile", style: TextStyle(color: Colors.white, fontSize: 16)), + const Spacer(), + Icon(Icons.arrow_forward_ios, color: Colors.grey.shade400), + ], + ), + ), + ), + InkWell( + onTap: () { + routeToPage(context, const PayoutHistory()); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Row( + children: [ + const Text("Payout History", style: TextStyle(color: Colors.white, fontSize: 16)), + const Spacer(), + Icon(Icons.arrow_forward_ios, color: Colors.grey.shade400), + ], + ), + ), + ), + ], + ), + ), + ); + }), + ); + } +} diff --git a/app/lib/pages/settings/creator_profile/creator_profile_details.dart b/app/lib/pages/settings/creator_profile/creator_profile_details.dart new file mode 100644 index 000000000..2df5f95d9 --- /dev/null +++ b/app/lib/pages/settings/creator_profile/creator_profile_details.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_profile_provider.dart'; +import 'package:friend_private/utils/other/text_formatters.dart'; +import 'package:friend_private/utils/other/validators.dart'; +import 'package:provider/provider.dart'; + +class CreatorProfileDetails extends StatelessWidget { + const CreatorProfileDetails({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.primary, + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.primary, + elevation: 0, + title: const Text('Creator Profile Details'), + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + body: provider.isLoading + ? const Center( + child: CircularProgressIndicator( + color: Colors.white, + ), + ) + : SingleChildScrollView( + child: Form( + key: provider.formKey, + onChanged: () { + provider.submitButtonStatus(); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 6, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Creator Name', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + controller: provider.creatorNameController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please provide a valid name'; + } + return null; + }, + decoration: const InputDecoration( + error: null, + errorText: null, + isDense: true, + border: InputBorder.none, + hintText: 'Nik Shevchenko', + ), + ), + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Creator Email', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + controller: provider.creatorEmailController, + inputFormatters: [ + LowercaseTextInputFormatter(), + ], + validator: (value) { + if (value == null || !isValidEmail(value)) { + return 'Please provide a valid Email Address for communication'; + } + return null; + }, + decoration: const InputDecoration( + errorText: null, + error: null, + isDense: true, + border: InputBorder.none, + hintText: 'Nik@basedhardware.com', + ), + ), + ), + const SizedBox( + height: 2, + ), + Row( + children: [ + const SizedBox( + width: 8, + ), + Icon( + Icons.info_outline, + color: Colors.grey.shade400, + size: 16, + ), + const SizedBox( + width: 8, + ), + SizedBox( + width: MediaQuery.sizeOf(context).width * 0.76, + child: Text( + 'This email will be public and used for communication.', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12), + ), + ), + ], + ) + ], + ), + ), + const SizedBox( + height: 16, + ), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 6, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'PayPal Email', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + controller: provider.paypalEmailController, + inputFormatters: [ + LowercaseTextInputFormatter(), + ], + validator: (value) { + if (value == null || !isValidEmail(value)) { + return 'Please provide a valid PayPal Email'; + } + return null; + }, + decoration: const InputDecoration( + errorText: null, + isDense: true, + border: InputBorder.none, + hintText: 'Nik Shevchenko', + ), + ), + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'PayPal.Me Link', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + inputFormatters: [ + LowercaseTextInputFormatter(), + ], + controller: provider.paypalMeLinkController, + validator: (value) { + if (value == null || !isValidPayPalMeLink(value)) { + return 'Please provide a valid PayPal.Me Link'; + } + return null; + }, + decoration: const InputDecoration( + errorText: null, + isDense: true, + border: InputBorder.none, + hintText: 'paypal.me/nikshevchenko', + ), + ), + ), + ], + ), + ) + ], + ), + ), + ), + ), + bottomNavigationBar: provider.isLoading + ? null + : Container( + padding: const EdgeInsets.only(left: 30.0, right: 30, bottom: 50, top: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: Colors.grey.shade900, + gradient: LinearGradient( + colors: [Colors.black, Colors.black.withOpacity(0)], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + child: GestureDetector( + onTap: provider.showSubmitButton + ? () async { + if (provider.profileExists) { + await provider.updateDetails(); + } else { + await provider.saveDetails(); + } + } + : null, + child: Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: provider.showSubmitButton ? Colors.white : Colors.grey.shade700, + ), + child: const Text( + 'Save Details', + style: TextStyle(color: Colors.black, fontSize: 16), + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + }); + } +} diff --git a/app/lib/pages/settings/creator_profile/creator_profile_provider.dart b/app/lib/pages/settings/creator_profile/creator_profile_provider.dart new file mode 100644 index 000000000..723ede38d --- /dev/null +++ b/app/lib/pages/settings/creator_profile/creator_profile_provider.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/http/api/users.dart'; +import 'package:friend_private/backend/schema/profile.dart'; +import 'package:friend_private/utils/alerts/app_snackbar.dart'; + +class CreatorProfileProvider extends ChangeNotifier { + final TextEditingController creatorNameController = TextEditingController(); + final TextEditingController creatorEmailController = TextEditingController(); + final TextEditingController paypalEmailController = TextEditingController(); + final TextEditingController paypalMeLinkController = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + bool showSubmitButton = false; + bool isLoading = false; + bool profileExists = false; + + int totalUsage = 0; + double totalEarnings = 0.0; + int publishedApps = 0; + int totalUsers = 0; + + List dummyPayoutHistory = List.generate(6, (index) => PayoutTransaction.empty()); + + List payoutHistory = []; + + CreatorProfileProvider() { + payoutHistory = dummyPayoutHistory; + getCreatorProfile(); + } + + void setIsLoading(bool value) { + isLoading = value; + notifyListeners(); + } + + void submitButtonStatus() { + if (creatorNameController.text.isNotEmpty && + creatorEmailController.text.isNotEmpty && + paypalEmailController.text.isNotEmpty && + paypalMeLinkController.text.isNotEmpty) { + showSubmitButton = true; + } else { + showSubmitButton = false; + } + notifyListeners(); + } + + Future getCreatorStats() async { + setIsLoading(true); + var res = await getCreatorStatsServer(); + if (res != null) { + totalUsage = res.usageCount; + totalEarnings = res.moneyMade; + publishedApps = res.appsCount; + totalUsers = res.activeUsers; + setIsLoading(false); + } else { + AppSnackbar.showSnackbarError('Failed to fetch your Apps stats'); + } + notifyListeners(); + } + + Future getPayoutHistory() async { + setIsLoading(true); + var res = await getPayoutHistoryServer(); + if (res != null) { + payoutHistory = res; + setIsLoading(false); + } else { + AppSnackbar.showSnackbarError('Failed to fetch your Payout History'); + } + notifyListeners(); + } + + Future getCreatorProfileDetails() async { + setIsLoading(true); + var res = await getCreatorProfile(); + if (res != null) { + if (res.isEmpty()) { + AppSnackbar.showSnackbarInfo('Please complete your profile to receive payments.'); + } else { + profileExists = true; + creatorNameController.text = res.creatorName; + creatorEmailController.text = res.creatorEmail; + paypalEmailController.text = res.paypalDetails.email; + paypalMeLinkController.text = res.paypalDetails.paypalMeLink ?? ''; + } + showSubmitButton = false; + setIsLoading(false); + } else { + AppSnackbar.showSnackbarError('Failed to fetch your creator profile details'); + } + notifyListeners(); + } + + Future updateDetails() async { + if (formKey.currentState!.validate()) { + setIsLoading(true); + var res = await updateCreatorProfileServer( + creatorNameController.text, + creatorEmailController.text, + paypalEmailController.text, + paypalMeLinkController.text, + ); + if (res) { + AppSnackbar.showSnackbarSuccess('Creator profile details updated successfully'); + } else { + AppSnackbar.showSnackbarError('Failed to update your creator profile details'); + } + showSubmitButton = false; + profileExists = true; + setIsLoading(false); + } else { + AppSnackbar.showSnackbarError('Please fill all the fields correctly'); + } + } + + Future saveDetails() async { + if (formKey.currentState!.validate()) { + setIsLoading(true); + var profile = CreatorProfile( + creatorName: creatorNameController.text, + creatorEmail: creatorEmailController.text, + paypalDetails: PayPalDetails( + email: paypalEmailController.text, + paypalMeLink: paypalMeLinkController.text, + ), + isVerified: false, + ); + var res = await saveCreatorProfile(profile); + if (res) { + profileExists = true; + AppSnackbar.showSnackbarSuccess('Creator profile details saved successfully'); + } else { + AppSnackbar.showSnackbarError('Failed to update your creator profile details'); + } + showSubmitButton = false; + setIsLoading(false); + } else { + AppSnackbar.showSnackbarError('Please fill all the fields correctly'); + } + } +} diff --git a/app/lib/pages/settings/creator_profile/payout_history.dart b/app/lib/pages/settings/creator_profile/payout_history.dart new file mode 100644 index 000000000..24f37da7b --- /dev/null +++ b/app/lib/pages/settings/creator_profile/payout_history.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +import 'creator_profile_provider.dart'; + +class PayoutHistory extends StatefulWidget { + const PayoutHistory({super.key}); + + @override + State createState() => _PayoutHistoryState(); +} + +class _PayoutHistoryState extends State { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await Provider.of(context, listen: false).getPayoutHistory(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.primary, + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.primary, + elevation: 0, + title: const Text('Payout History'), + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + body: Consumer(builder: (context, provider, child) { + return Skeletonizer( + enabled: provider.isLoading, + child: ListView.builder( + itemBuilder: (ctx, idx) { + return Container( + padding: const EdgeInsets.all(16.0), + margin: const EdgeInsets.only(left: 8.0, right: 8.0, top: 12, bottom: 6), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(16.0), + ), + child: Column( + children: [ + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("${provider.payoutHistory[idx].amount} ${provider.payoutHistory[idx].currency}", + style: const TextStyle(color: Colors.white, fontSize: 20)), + const SizedBox( + height: 4, + ), + Text(dateTimeFormat('MMM d, h:mm a', provider.payoutHistory[idx].date), + style: const TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + const Spacer(), + getStatusChip(provider.payoutHistory[idx].paymentStatus), + ], + ), + const SizedBox( + height: 6, + ), + Divider( + color: Colors.grey.shade700, + thickness: 1, + ), + const SizedBox( + height: 6, + ), + Row( + children: [ + const Text('Payment Method', style: TextStyle(color: Colors.white)), + const Spacer(), + Text(provider.payoutHistory[idx].payoutMethodText(), + style: const TextStyle(color: Colors.white)), + ], + ), + ], + ), + ); + }, + itemCount: provider.payoutHistory.length, + ), + ); + }), + ); + } + + Widget getStatusChip(String status) { + if (status == 'pending') { + return const Chip( + label: Text("Pending", style: TextStyle(color: Colors.white)), + ); + } else if (status == 'failed') { + return Chip( + backgroundColor: Colors.red.withOpacity(0.4), + label: const Text("Failed", style: TextStyle(color: Colors.red)), + ); + } else { + return Chip( + backgroundColor: const Color(0xFF4CAF50).withOpacity(0.4), + label: const Text("Successful", style: TextStyle(color: Color.fromARGB(255, 40, 231, 46))), + ); + } + } +} diff --git a/app/lib/pages/settings/page.dart b/app/lib/pages/settings/page.dart index 13de76ce4..03c3cc7fd 100644 --- a/app/lib/pages/settings/page.dart +++ b/app/lib/pages/settings/page.dart @@ -3,6 +3,7 @@ import 'package:friend_private/backend/auth.dart'; import 'package:friend_private/backend/preferences.dart'; import 'package:friend_private/main.dart'; import 'package:friend_private/pages/settings/about.dart'; +import 'package:friend_private/pages/settings/creator_profile/creator_dashboard.dart'; import 'package:friend_private/pages/settings/developer.dart'; import 'package:friend_private/pages/settings/profile.dart'; import 'package:friend_private/pages/settings/widgets.dart'; @@ -80,6 +81,12 @@ class _SettingsPageState extends State { () => routeToPage(context, const ProfilePage()), icon: Icons.person, ), + const SizedBox(height: 8), + getItemAddOn2( + 'Creator Dashboard', + () => routeToPage(context, const CreatorDashboard()), + icon: Icons.person, + ), const SizedBox(height: 20), getItemAddOn2( 'Device Settings', diff --git a/app/lib/utils/alerts/app_snackbar.dart b/app/lib/utils/alerts/app_snackbar.dart index 30bd7c7a8..5dff1d021 100644 --- a/app/lib/utils/alerts/app_snackbar.dart +++ b/app/lib/utils/alerts/app_snackbar.dart @@ -27,4 +27,12 @@ class AppSnackbar { duration: duration, ); } + + static void showSnackbarInfo(String message, {Duration? duration}) { + showSnackbar( + message, + color: Colors.grey.shade800, + duration: duration, + ); + } } diff --git a/app/lib/utils/other/text_formatters.dart b/app/lib/utils/other/text_formatters.dart new file mode 100644 index 000000000..681bff001 --- /dev/null +++ b/app/lib/utils/other/text_formatters.dart @@ -0,0 +1,11 @@ +import 'package:flutter/services.dart'; + +class LowercaseTextInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return TextEditingValue( + text: newValue.text.toLowerCase(), + selection: newValue.selection, + ); + } +} diff --git a/app/lib/utils/other/validators.dart b/app/lib/utils/other/validators.dart index edbd6a0d1..aaca37425 100644 --- a/app/lib/utils/other/validators.dart +++ b/app/lib/utils/other/validators.dart @@ -18,3 +18,8 @@ bool isValidEmail(String email) { const emailPattern = r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'; return RegExp(emailPattern).hasMatch(email); } + +bool isValidPayPalMeLink(String url) { + const payPalMePattern = r'^(?:https?:\/\/)?(?:www\.)?paypal\.me\/[a-zA-Z0-9]{1,100}$'; + return RegExp(payPalMePattern).hasMatch(url); +} diff --git a/backend/database/apps.py b/backend/database/apps.py index 1623e8e2d..fd73ec9d2 100644 --- a/backend/database/apps.py +++ b/backend/database/apps.py @@ -30,6 +30,22 @@ def migrate_reviews_from_redis_to_firestore(): new_app_ref.set(review) +def get_user_public_apps_db(uid: str) -> List: + filters = [FieldFilter('uid', '==', uid), FieldFilter('deleted', '==', False), FieldFilter('private', '==', False)] + user_apps = db.collection('plugins_data').where(filter=BaseCompositeFilter('AND', filters)).stream() + return [doc.to_dict() for doc in user_apps] + + +def batch_update_creator_profile_for_apps_db(uid: str, author: str, email: str): + filters = [FieldFilter('uid', '==', uid), FieldFilter('deleted', '==', False)] + user_apps = db.collection('plugins_data').where(filter=BaseCompositeFilter('AND', filters)).stream() + batch = db.batch() + for doc in user_apps: + app_ref = db.collection('plugins_data').document(doc.id) + batch.update(app_ref, {'author': author, 'email': email}) + batch.commit() + + def get_app_by_id_db(app_id: str): app_ref = db.collection('plugins_data').document(app_id) doc = app_ref.get() diff --git a/backend/database/redis_db.py b/backend/database/redis_db.py index d437db132..8355d2679 100644 --- a/backend/database/redis_db.py +++ b/backend/database/redis_db.py @@ -85,6 +85,34 @@ def get_app_usage_count_cache(app_id: str) -> int | None: return eval(count) +def get_multiple_apps_usage_count_cache(app_ids: list) -> dict: + if not app_ids: + return {} + + keys = [f'apps:{app_id}:usage_count' for app_id in app_ids] + counts = r.mget(keys) + if counts is None: + return {} + return { + app_id: eval(count) if count else 0 + for app_id, count in zip(app_ids, counts) + } + + +def get_multiple_apps_money_made_amount_cache(app_ids: list) -> dict: + if not app_ids: + return {} + + keys = [f'apps:{app_id}:money_made' for app_id in app_ids] + amounts = r.mget(keys) + if amounts is None: + return {} + return { + app_id: eval(amount) if amount else 0 + for app_id, amount in zip(app_ids, amounts) + } + + def set_app_money_made_amount_cache(app_id: str, amount: float): r.set(f'apps:{app_id}:money_made', amount, ex=60 * 15) # 15 minutes @@ -169,6 +197,21 @@ def enable_app(uid: str, app_id: str): r.sadd(f'users:{uid}:enabled_plugins', app_id) +def get_app_enabled_count(app_id: str) -> int: + count = r.scard(f'users:*:enabled_plugins') + return count + + +def get_multiple_apps_enabled_count_cache(app_ids: list) -> dict: + if not app_ids: + return {} + + counts = {} + for app_id in app_ids: + counts[app_id] = r.scard(f'users:*:enabled_plugins') + return counts + + def disable_app(uid: str, app_id: str): r.srem(f'users:{uid}:enabled_plugins', app_id) diff --git a/backend/database/users.py b/backend/database/users.py index 7ebfbdc33..884f29f63 100644 --- a/backend/database/users.py +++ b/backend/database/users.py @@ -85,6 +85,32 @@ def delete_user_data(uid: str): return {'status': 'ok', 'message': 'Account deleted successfully'} +# *********************************************** +# *************** CREATOR PROFILE *************** +# *********************************************** +def get_user_creator_profile_db(uid: str): + user_ref = db.collection('users').document(uid) + user_data = user_ref.get().to_dict() + return user_data.get('creator_profile', {}) + + +def set_user_creator_profile_db(uid: str, data: dict): + user_ref = db.collection('users').document(uid) + user_ref.update({'creator_profile': data}) + + +def create_manual_payment_db(uid: str, data: dict): + manual_payment_ref = db.collection('users').document(uid).collection('payments') + manual_payment_ref.document().set(data) + return data + + +def get_all_user_payouts_db(uid: str): + payments_ref = db.collection('users').document(uid).collection('payments') + payments = payments_ref.stream() + return [payment.to_dict() for payment in payments] + + # ************************************** # ************* Analytics ************** # ************************************** diff --git a/backend/models/app.py b/backend/models/app.py index c8d82b1ff..1b9e3783b 100644 --- a/backend/models/app.py +++ b/backend/models/app.py @@ -128,3 +128,25 @@ class UsageHistoryItem(BaseModel): memory_id: Optional[str] = None timestamp: datetime type: UsageHistoryType + + +# ****************************************************** +# ***************** APP REQUEST MODELS ***************** +# ****************************************************** + +class SubmitAppRequest(BaseModel): + name: str + private: bool = False + approved: bool = False + status: str = 'under-review' + category: str + description: str + capabilities: Set[str] + memory_prompt: Optional[str] = None + chat_prompt: Optional[str] = None + external_integration: Optional[ExternalIntegration] = None + deleted: bool = False + proactive_notification: Optional[ProactiveNotification] = None + is_paid: bool = False + price: float = 0.0 # cents/100 + payment_plan: Optional[str] = None \ No newline at end of file diff --git a/backend/models/users.py b/backend/models/users.py index 5ea200c1a..84c3de503 100644 --- a/backend/models/users.py +++ b/backend/models/users.py @@ -1,5 +1,9 @@ +from datetime import datetime from enum import Enum +from pydantic import BaseModel +from typing import Optional + class WebhookType(str, Enum): audio_bytes = 'audio_bytes' @@ -7,3 +11,35 @@ class WebhookType(str, Enum): realtime_transcript = 'realtime_transcript' memory_created = 'memory_created', day_summary = 'day_summary' + + +class PayPalDetails(BaseModel): + paypal_email: str + paypal_me_link: Optional[str] = None + + +class CreatorProfileRequest(BaseModel): + creator_name: str + creator_email: str + paypal_details: PayPalDetails + + +class Amount(BaseModel): + value: str + currency_code: str + + +class Payee(BaseModel): + email: str + uid: str + payment_method: str + + +class ManualPaymentRequest(BaseModel): + amount: Amount + payment_method: str + payment_mode: str + payment_status: str + payee: Payee + description: Optional[str] = None + supplementary_data: Optional[dict] = None diff --git a/backend/routers/apps.py b/backend/routers/apps.py index 9bb732691..f22db1f48 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import List import requests +from pydantic import ValidationError from ulid import ULID from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException, Header @@ -20,8 +21,9 @@ from utils.notifications import send_notification from utils.other import endpoints as auth -from models.app import App +from models.app import App, SubmitAppRequest from utils.other.storage import upload_plugin_logo, delete_plugin_logo +from utils.user import get_user_creator_profile router = APIRouter() @@ -87,17 +89,60 @@ def create_app(app_data: str = Form(...), file: UploadFile = File(...), uid=Depe return {'status': 'ok'} +@router.post('/v2/apps', tags=['v2']) +def submit_app_v2(app_data: str = Form(...), file: UploadFile = File(...), uid=Depends(auth.get_current_user_uid)): + try: + parsed_data = json.loads(app_data) + validated_data = SubmitAppRequest(**parsed_data) + except (json.JSONDecodeError, ValidationError) as e: + raise HTTPException(status_code=422, detail=str(e)) + data = validated_data.dict() + data['name'] = data['name'].strip() + data['id'] = str(ULID()) + data['uid'] = uid + creator_profile = get_user_creator_profile(uid) + if not creator_profile: + raise HTTPException(status_code=403, detail='Your creator profile is not set up') + data['author'] = creator_profile['creator_name'] + data['email'] = creator_profile['creator_email'] + if external_integration := data.get('external_integration'): + if external_integration.get('triggers_on') is None: + raise HTTPException(status_code=422, detail='Triggers on is required') + # check if setup_instructions_file_path is a single url or a just a string of text + if external_integration.get('setup_instructions_file_path'): + external_integration['setup_instructions_file_path'] = external_integration[ + 'setup_instructions_file_path'].strip() + if external_integration['setup_instructions_file_path'].startswith('http'): + external_integration['is_instructions_url'] = True + else: + external_integration['is_instructions_url'] = False + os.makedirs(f'_temp/apps', exist_ok=True) + file_path = f"_temp/apps/{file.filename}" + with open(file_path, 'wb') as f: + f.write(file.file.read()) + img_url = upload_plugin_logo(file_path, data['id']) + data['image'] = img_url + data['created_at'] = datetime.now(timezone.utc) + add_app_to_db(data) + + # payment link + app = App(**data) + upsert_app_payment_link(app.id, app.is_paid, app.price, app.payment_plan) + + return {'status': 'ok'} + + @router.patch('/v1/apps/{app_id}', tags=['v1']) def update_app(app_id: str, app_data: str = Form(...), file: UploadFile = File(None), uid=Depends(auth.get_current_user_uid)): data = json.loads(app_data) - plugin = get_available_app_by_id(app_id, uid) - if not plugin: + app = get_available_app_by_id(app_id, uid) + if not app: raise HTTPException(status_code=404, detail='App not found') - if plugin['uid'] != uid: + if app['uid'] != uid: raise HTTPException(status_code=403, detail='You are not authorized to perform this action') if file: - delete_plugin_logo(plugin['image']) + delete_plugin_logo(app['image']) os.makedirs(f'_temp/plugins', exist_ok=True) file_path = f"_temp/plugins/{file.filename}" with open(file_path, 'wb') as f: @@ -110,9 +155,9 @@ def update_app(app_id: str, app_data: str = Form(...), file: UploadFile = File(N # payment link upsert_app_payment_link(data.get('id'), data.get('is_paid', False), data.get('price'), data.get('payment_plan'), - previous_price=plugin.get("price", 0)) + previous_price=app.get("price", 0)) - if plugin['approved'] and (plugin['private'] is None or plugin['private'] is False): + if app['approved'] and (app['private'] is None or app['private'] is False): delete_generic_cache('get_public_approved_apps_data') delete_app_cache_by_id(app_id) return {'status': 'ok'} diff --git a/backend/routers/users.py b/backend/routers/users.py index 6ed96b1ac..d6cd01e1b 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -1,20 +1,26 @@ +import os import threading import uuid from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Header +from database.apps import get_user_public_apps_db, batch_update_creator_profile_for_apps_db from database.memories import get_in_progress_memory, get_memory from database.redis_db import cache_user_geolocation, set_user_webhook_db, get_user_webhook_db, disable_user_webhook_db, \ enable_user_webhook_db, user_webhook_status_db from database.users import * from models.memory import Geolocation, Memory from models.other import Person, CreatePerson -from models.users import WebhookType +from models.users import WebhookType, CreatorProfileRequest, ManualPaymentRequest +from utils.apps import get_multiple_apps_usage_count, get_multiple_apps_money_made_amount, \ + get_multiple_apps_enabled_count from utils.llm import followup_question_prompt from utils.other import endpoints as auth from utils.other.storage import delete_all_memory_recordings, get_user_person_speech_samples, \ delete_user_person_speech_samples +from utils.user import get_user_creator_profile, create_user_creator_profile, update_user_creator_profile, \ + update_creator_details_for_user_apps from utils.webhooks import webhook_first_time_setup router = APIRouter() @@ -38,6 +44,54 @@ def set_user_geolocation(geolocation: Geolocation, uid: str = Depends(auth.get_c return {'status': 'ok'} +# *********************************************** +# *************** CREATOR PROFILE *************** +# *********************************************** + +@router.post('/v1/users/creator-profile', tags=['v1']) +def set_creator_profile(data: CreatorProfileRequest, uid: str = Depends(auth.get_current_user_uid)): + data = data.dict() + data['created_at'] = datetime.now(timezone.utc) + data['is_verified'] = False + create_user_creator_profile(uid, data) + return {'status': 'ok'} + + +@router.get('/v1/users/creator-profile', tags=['v1']) +def get_creator_profile(uid: str = Depends(auth.get_current_user_uid)): + return get_user_creator_profile(uid) + + +@router.patch('/v1/users/creator-profile', tags=['v1']) +def update_creator_profile(data: dict, uid: str = Depends(auth.get_current_user_uid)): + current_data = get_user_creator_profile(uid) + current_data.update(data) + current_data['updated_at'] = datetime.now(timezone.utc) + update_user_creator_profile(uid, current_data) + update_creator_details_for_user_apps(uid, current_data) + return {'status': 'ok'} + + +@router.get('/v1/users/creator-stats', tags=['v1']) +def get_creator_stats(uid: str = Depends(auth.get_current_user_uid)): + apps = get_user_public_apps_db(uid) + app_ids = [app['id'] for app in apps] + usage_count = get_multiple_apps_usage_count(app_ids) + money_made = get_multiple_apps_money_made_amount(app_ids) + users_count = get_multiple_apps_enabled_count(app_ids) + return { + 'usage_count': usage_count, + 'money_made': money_made, + 'apps_count': app_ids, + 'active_users': users_count, + } + + +@router.get('/v1/users/payout-history', tags=['v1']) +def get_creator_stats(uid: str = Depends(auth.get_current_user_uid)): + return get_all_user_payouts_db(uid) + + # *********************************************** # ************* DEVELOPER WEBHOOKS ************** # *********************************************** @@ -226,3 +280,17 @@ def set_chat_message_analytics( ): set_chat_message_rating_score(uid, message_id, value) return {'status': 'ok'} + + +# **************************************** +# ************ TEAM ENDPOINTS ************ +# **************************************** + +@router.post('/v1/users/{uid}/manual-payment', tags=['v1']) +def create_manual_payment(data: ManualPaymentRequest, uid: str,secret_key: str = Header(...)): + if secret_key != os.getenv('ADMIN_KEY'): + raise HTTPException(status_code=403, detail='You are not authorized to perform this action') + data = data.dict() + data['payment_date'] = datetime.now(timezone.utc) + create_manual_payment_db(uid, data) + return {'status': 'ok'} diff --git a/backend/utils/apps.py b/backend/utils/apps.py index c59038ee1..91bb7f3f8 100644 --- a/backend/utils/apps.py +++ b/backend/utils/apps.py @@ -13,7 +13,8 @@ set_generic_cache, set_app_usage_history_cache, get_app_usage_history_cache, get_app_money_made_cache, \ set_app_money_made_cache, get_plugins_installs_count, get_plugins_reviews, get_app_cache_by_id, set_app_cache_by_id, \ set_app_review_cache, get_app_usage_count_cache, set_app_money_made_amount_cache, get_app_money_made_amount_cache, \ - set_app_usage_count_cache, set_user_paid_app, get_user_paid_app + set_app_usage_count_cache, get_multiple_apps_usage_count_cache, get_multiple_apps_money_made_amount_cache, \ + get_multiple_apps_enabled_count_cache, set_user_paid_app, get_user_paid_app from models.app import App, UsageHistoryItem, UsageHistoryType from utils import stripe @@ -59,6 +60,18 @@ def weighted_rating(plugin): return (v / (v + m) * R) + (m / (v + m) * C) +def get_multiple_apps_usage_count(app_ids: List[str]) -> dict: + return get_multiple_apps_usage_count_cache(app_ids) + + +def get_multiple_apps_money_made_amount(app_ids: List[str]) -> dict: + return get_multiple_apps_money_made_amount_cache(app_ids) + + +def get_multiple_apps_enabled_count(app_ids: List[str]) -> dict: + return get_multiple_apps_enabled_count_cache(app_ids) + + def get_available_apps(uid: str, include_reviews: bool = False) -> List[App]: private_data = [] public_approved_data = [] diff --git a/backend/utils/user.py b/backend/utils/user.py new file mode 100644 index 000000000..81f005ee4 --- /dev/null +++ b/backend/utils/user.py @@ -0,0 +1,18 @@ +from database.apps import batch_update_creator_profile_for_apps_db +from database.users import get_user_creator_profile_db, set_user_creator_profile_db + + +def get_user_creator_profile(uid: str): + return get_user_creator_profile_db(uid) + + +def update_user_creator_profile(uid: str, data: dict): + return set_user_creator_profile_db(uid, data) + + +def update_creator_details_for_user_apps(uid: str, data: dict): + return batch_update_creator_profile_for_apps_db(uid, data['creator_name'], data['creator_email']) + + +def create_user_creator_profile(uid: str, data: dict): + return set_user_creator_profile_db(uid, data)