diff --git a/app/lib/backend/http/api/apps.dart b/app/lib/backend/http/api/apps.dart index c31827d99..d5550f2aa 100644 --- a/app/lib/backend/http/api/apps.dart +++ b/app/lib/backend/http/api/apps.dart @@ -75,6 +75,34 @@ Future reviewApp(String appId, AppReview review) async { } } +Future> uploadAppThumbnail(File file) async { + var request = http.MultipartRequest( + 'POST', + Uri.parse('${Env.apiBaseUrl}v1/app/thumbnails'), + ); + request.files.add(await http.MultipartFile.fromPath('file', file.path, filename: basename(file.path))); + request.headers.addAll({'Authorization': await getAuthHeader()}); + + try { + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 200) { + var data = jsonDecode(response.body); + return { + 'thumbnail_url': data['thumbnail_url'], + 'thumbnail_id': data['thumbnail_id'], + }; + } else { + debugPrint('Failed to upload thumbnail. Status code: ${response.statusCode}'); + return {}; + } + } catch (e) { + debugPrint('An error occurred uploading thumbnail: $e'); + return {}; + } +} + Future updateAppReview(String appId, AppReview review) async { try { var response = await makeApiCall( @@ -157,7 +185,7 @@ Future isAppSetupCompleted(String? url) async { } } -Future<(bool, String)> submitAppServer(File file, Map appData) async { +Future<(bool, String, String?)> submitAppServer(File file, Map appData) async { var request = http.MultipartRequest( 'POST', Uri.parse('${Env.apiBaseUrl}v1/apps'), @@ -165,25 +193,31 @@ Future<(bool, String)> submitAppServer(File file, Map appData) request.files.add(await http.MultipartFile.fromPath('file', file.path, filename: basename(file.path))); request.headers.addAll({'Authorization': await getAuthHeader()}); request.fields.addAll({'app_data': jsonEncode(appData)}); - print(jsonEncode(appData)); + debugPrint(jsonEncode(appData)); try { var streamedResponse = await request.send(); var response = await http.Response.fromStream(streamedResponse); if (response.statusCode == 200) { - debugPrint('submitAppServer Response body: ${jsonDecode(response.body)}'); - return (true, ''); + var respData = jsonDecode(response.body); + String? appId = respData['app_id']; + debugPrint('submitAppServer Response body: ${respData}'); + return (true, '', appId); } else { debugPrint('Failed to submit app. Status code: ${response.statusCode}'); if (response.body.isNotEmpty) { - return (false, jsonDecode(response.body)['detail'] as String); + return ( + false, + jsonDecode(response.body)['detail'] as String, + null, + ); } else { - return (false, 'Failed to submit app. Please try again later'); + return (false, 'Failed to submit app. Please try again later', ''); } } } catch (e) { debugPrint('An error occurred submitAppServer: $e'); - return (false, 'Failed to submit app. Please try again later'); + return (false, 'Failed to submit app. Please try again later', null); } } @@ -197,7 +231,7 @@ Future updateAppServer(File? file, Map appData) async { } request.headers.addAll({'Authorization': await getAuthHeader()}); request.fields.addAll({'app_data': jsonEncode(appData)}); - print(jsonEncode(appData)); + debugPrint(jsonEncode(appData)); try { var streamedResponse = await request.send(); var response = await http.Response.fromStream(streamedResponse); diff --git a/app/lib/backend/schema/app.dart b/app/lib/backend/schema/app.dart index c58633ed1..980eba08d 100644 --- a/app/lib/backend/schema/app.dart +++ b/app/lib/backend/schema/app.dart @@ -189,6 +189,8 @@ class App { double? price; bool isUserPaid; String? paymentLink; + List thumbnailIds; + List thumbnailUrls; App({ required this.id, @@ -221,6 +223,8 @@ class App { this.price, required this.isUserPaid, this.paymentLink, + this.thumbnailIds = const [], + this.thumbnailUrls = const [], }); String? getRatingAvg() => ratingAvg?.toStringAsFixed(1); @@ -268,6 +272,8 @@ class App { price: json['price'] ?? 0.0, isUserPaid: json['is_user_paid'] ?? false, paymentLink: json['payment_link'], + thumbnailIds: (json['thumbnails'] as List?)?.cast() ?? [], + thumbnailUrls: (json['thumbnail_urls'] as List?)?.cast() ?? [], ); } diff --git a/app/lib/pages/apps/add_app.dart b/app/lib/pages/apps/add_app.dart index 3ed05cb61..c4a0597a5 100644 --- a/app/lib/pages/apps/add_app.dart +++ b/app/lib/pages/apps/add_app.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:friend_private/backend/preferences.dart'; +import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/pages/apps/app_detail/app_detail.dart'; import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/apps/widgets/app_metadata_widget.dart'; import 'package:friend_private/pages/apps/widgets/external_trigger_fields_widget.dart'; import 'package:friend_private/pages/apps/widgets/notification_scopes_chips_widget.dart'; import 'package:friend_private/pages/apps/widgets/payment_details_widget.dart'; +import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/utils/analytics/mixpanel.dart'; +import 'package:friend_private/utils/other/temp.dart'; import 'package:friend_private/widgets/confirmation_dialog.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -97,9 +104,7 @@ class _AddAppPageState extends State { ), ), ), - const SizedBox( - height: 18, - ), + const SizedBox(height: 18), AppMetadataWidget( pickImage: () async { await provider.pickImage(); @@ -122,9 +127,143 @@ class _AddAppPageState extends State { paymentPlan: provider.mapPaymentPlanIdToName(provider.selectePaymentPlan), ) : const SizedBox.shrink(), - const SizedBox( - height: 12, + const SizedBox(height: 18), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Preview and Screenshots', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: provider.thumbnailUrls.length + 1, + itemBuilder: (context, index) { + // Calculate dimensions to maintain 2:3 ratio + final width = 120.0; + final height = width * 1.5; // 2:3 ratio + + if (index == provider.thumbnailUrls.length) { + return GestureDetector( + onTap: provider.isUploadingThumbnail ? null : provider.pickThumbnail, + child: Container( + width: width, + height: height, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: provider.isUploadingThumbnail + ? Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[800]!, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.photo, size: 32), + ), + ) + : const Icon(Icons.add_photo_alternate_outlined, size: 32), + ), + ); + } + return Stack( + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FullScreenImageViewer( + imageUrl: provider.thumbnailUrls[index], + ), + ), + ); + }, + child: CachedNetworkImage( + imageUrl: provider.thumbnailUrls[index], + imageBuilder: (context, imageProvider) => Container( + width: 120, + height: 180, // 2:3 ratio (120 * 1.5) + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF424242), + width: 1, + ), + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + placeholder: (context, url) => Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[800]!, + child: Container( + width: 120, + height: 180, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: 120, + height: 180, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.error), + ), + ), + ), + Positioned( + top: 4, + right: 12, + child: GestureDetector( + onTap: () => provider.removeThumbnail(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 16), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), ), + const SizedBox(height: 18), Container( decoration: BoxDecoration( color: Colors.grey.shade900, @@ -338,7 +477,15 @@ class _AddAppPageState extends State { } SharedPreferencesUtil().showSubmitAppConfirmation = showSubmitAppConfirmation; Navigator.pop(context); - await provider.submitApp(); + String? appId = await provider.submitApp(); + App? app; + if (appId != null) { + app = await context.read().getAppFromId(appId); + } + if (app != null && mounted && context.mounted) { + Navigator.pop(context); + routeToPage(context, AppDetailPage(app: app)); + } }, onCancel: () { Navigator.pop(context); diff --git a/app/lib/pages/apps/app_detail/app_detail.dart b/app/lib/pages/apps/app_detail/app_detail.dart index 6d9944a21..b04180678 100644 --- a/app/lib/pages/apps/app_detail/app_detail.dart +++ b/app/lib/pages/apps/app_detail/app_detail.dart @@ -1,6 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart'; import 'package:flutter_rating_bar/flutter_rating_bar.dart'; import 'package:friend_private/backend/http/api/apps.dart'; import 'package:friend_private/backend/preferences.dart'; @@ -605,6 +607,96 @@ class _AppDetailPageState extends State { ), ) : const SizedBox.shrink(), + if (app.thumbnailUrls.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 16), + child: Text( + 'Preview', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + ), + SizedBox( + height: MediaQuery.of(context).size.width * 0.9, + child: ListView.builder( + padding: EdgeInsets.zero, + scrollDirection: Axis.horizontal, + itemCount: app.thumbnailUrls.length, + itemBuilder: (context, index) { + final screenWidth = MediaQuery.of(context).size.width; + // Calculate width to show 1.5 thumbnails + final width = screenWidth * 0.65; + // Calculate height to maintain 2:3 ratio (height = width * 1.5) + final height = width * 1.5; + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FullScreenImageViewer( + imageUrl: app.thumbnailUrls[index], + ), + ), + ); + }, + child: CachedNetworkImage( + imageUrl: app.thumbnailUrls[index], + imageBuilder: (context, imageProvider) => Container( + width: width, + height: height, + clipBehavior: Clip.hardEdge, + margin: EdgeInsets.only( + left: index == 0 ? 16 : 8, + right: index == app.thumbnailUrls.length - 1 ? 16 : 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF424242), + width: 1, + ), + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + placeholder: (context, url) => Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[800]!, + child: Container( + width: width, + height: height, + margin: EdgeInsets.only( + left: index == 0 ? 16 : 8, + right: index == app.thumbnailUrls.length - 1 ? 16 : 8, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: width, + height: height, + margin: EdgeInsets.only( + left: index == 0 ? 16 : 8, + right: index == app.thumbnailUrls.length - 1 ? 16 : 8, + ), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.error), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], InfoCardWidget( onTap: () { if (app.description.decodeString.characters.length > 200) { @@ -658,7 +750,7 @@ class _AppDetailPageState extends State { }, child: Container( width: double.infinity, - padding: const EdgeInsets.all(18.0), + 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, diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index c9c269f09..24f55c72b 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -52,6 +52,10 @@ class AddAppProvider extends ChangeNotifier { File? imageFile; String? imageUrl; String? updateAppId; + + List thumbnailUrls = []; + List thumbnailIds = []; + bool isUploadingThumbnail = false; List selectedCapabilities = []; List selectedScopes = []; List capabilities = []; @@ -151,6 +155,10 @@ class AddAppProvider extends ChangeNotifier { selectedScopes = app.getNotificationScopesFromIds( capabilities.firstWhere((element) => element.id == 'proactive_notification').notificationScopes); } + + // Set existing thumbnails + thumbnailUrls = app.thumbnailUrls; + thumbnailIds = app.thumbnailIds; isValid = false; setIsLoading(false); notifyListeners(); @@ -180,6 +188,8 @@ class AddAppProvider extends ChangeNotifier { selectedScopes.clear(); updateAppId = null; selectedCapabilities.clear(); + thumbnailUrls = []; + thumbnailIds = []; } void setPaymentPlan(String? plan) { @@ -405,7 +415,7 @@ class AddAppProvider extends ChangeNotifier { } } - Future updateApp() async { + Future updateApp() async { setIsUpdating(true); Map data = { @@ -422,6 +432,7 @@ class AddAppProvider extends ChangeNotifier { 'is_paid': isPaid, 'price': priceController.text.isNotEmpty ? double.parse(priceController.text) : 0.0, 'payment_plan': selectePaymentPlan, + 'thumbnails': thumbnailIds, }; for (var capability in selectedCapabilities) { if (capability.id == 'external_integration') { @@ -453,21 +464,25 @@ class AddAppProvider extends ChangeNotifier { data['proactive_notification']['scopes'] = selectedScopes.map((e) => e.id).toList(); } } + var success = false; var res = await updateAppServer(imageFile, data); if (res) { + await appProvider!.getApps(); var app = await getAppDetailsServer(updateAppId!); appProvider!.updateLocalApp(App.fromJson(app!)); AppSnackbar.showSnackbarSuccess('App updated successfully 🚀'); clear(); - appProvider!.getApps(); + success = true; } else { AppSnackbar.showSnackbarError('Failed to update app. Please try again later'); + success = false; } checkValidity(); setIsUpdating(false); + return success; } - Future submitApp() async { + Future submitApp() async { setIsSubmitting(true); Map data = { @@ -483,6 +498,7 @@ class AddAppProvider extends ChangeNotifier { 'is_paid': isPaid, 'price': priceController.text.isNotEmpty ? double.parse(priceController.text) : 0.0, 'payment_plan': selectePaymentPlan, + 'thumbnails': thumbnailIds, }; for (var capability in selectedCapabilities) { if (capability.id == 'external_integration') { @@ -514,16 +530,60 @@ class AddAppProvider extends ChangeNotifier { data['proactive_notification']['scopes'] = selectedScopes.map((e) => e.id).toList(); } } + String? appId; var res = await submitAppServer(imageFile!, data); if (res.$1) { AppSnackbar.showSnackbarSuccess('App submitted successfully 🚀'); - appProvider!.getApps(); + await appProvider!.getApps(); clear(); + appId = res.$3; } else { AppSnackbar.showSnackbarError(res.$2); } checkValidity(); setIsSubmitting(false); + return appId; + } + + Future pickThumbnail() async { + ImagePicker imagePicker = ImagePicker(); + try { + var file = await imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + ); + if (file != null) { + setIsUploadingThumbnail(true); + var thumbnailFile = File(file.path); + + // Upload thumbnail + var result = await uploadAppThumbnail(thumbnailFile); + if (result.isNotEmpty) { + thumbnailUrls.add(result['thumbnail_url']!); + thumbnailIds.add(result['thumbnail_id']!); + } + setIsUploadingThumbnail(false); + } + } on PlatformException catch (e) { + if (e.code == 'photo_access_denied') { + AppSnackbar.showSnackbarError('Photos permission denied. Please allow access to photos to select an image'); + } + setIsUploadingThumbnail(false); + } + checkValidity(); + notifyListeners(); + } + + void setIsUploadingThumbnail(bool uploading) { + isUploadingThumbnail = uploading; + notifyListeners(); + } + + void removeThumbnail(int index) { + thumbnailUrls.removeAt(index); + thumbnailIds.removeAt(index); + checkValidity(); + notifyListeners(); } Future pickImage() async { diff --git a/app/lib/pages/apps/update_app.dart b/app/lib/pages/apps/update_app.dart index a125299b0..5f769d3c1 100644 --- a/app/lib/pages/apps/update_app.dart +++ b/app/lib/pages/apps/update_app.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart'; import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/apps/widgets/notification_scopes_chips_widget.dart'; import 'package:friend_private/widgets/dialog.dart'; @@ -124,9 +127,143 @@ class _UpdateAppPageState extends State { paymentPlan: provider.mapPaymentPlanIdToName(provider.selectePaymentPlan), ) : const SizedBox.shrink(), - const SizedBox( - height: 12, + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Preview and Screenshots', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: provider.thumbnailUrls.length + 1, + itemBuilder: (context, index) { + // Calculate dimensions to maintain 2:3 ratio + final width = 120.0; + final height = width * 1.5; // 2:3 ratio + + if (index == provider.thumbnailUrls.length) { + return GestureDetector( + onTap: provider.isUploadingThumbnail ? null : provider.pickThumbnail, + child: Container( + width: width, + height: height, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: provider.isUploadingThumbnail + ? Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[800]!, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.photo, size: 32), + ), + ) + : const Icon(Icons.add_photo_alternate_outlined, size: 32), + ), + ); + } + return Stack( + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FullScreenImageViewer( + imageUrl: provider.thumbnailUrls[index], + ), + ), + ); + }, + child: CachedNetworkImage( + imageUrl: provider.thumbnailUrls[index], + imageBuilder: (context, imageProvider) => Container( + width: 120, + height: 180, // 2:3 ratio (120 * 1.5) + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF424242), + width: 1, + ), + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + placeholder: (context, url) => Shimmer.fromColors( + baseColor: Colors.grey[900]!, + highlightColor: Colors.grey[800]!, + child: Container( + width: 120, + height: 180, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: 120, + height: 180, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.error), + ), + ), + ), + Positioned( + top: 4, + right: 12, + child: GestureDetector( + onTap: () => provider.removeThumbnail(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 16), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), ), + const SizedBox(height: 12), Container( decoration: BoxDecoration( color: Colors.grey.shade900, @@ -268,7 +405,10 @@ class _UpdateAppPageState extends State { () => Navigator.pop(context), () async { Navigator.pop(context); - await provider.updateApp(); + bool ok = await provider.updateApp(); + if (ok) { + Navigator.pop(context); + } }, 'Update App?', 'Are you sure you want to update your app? The changes will reflect once reviewed by our team.', diff --git a/app/lib/pages/apps/widgets/full_screen_image_viewer.dart b/app/lib/pages/apps/widgets/full_screen_image_viewer.dart new file mode 100644 index 000000000..ac28ebdd1 --- /dev/null +++ b/app/lib/pages/apps/widgets/full_screen_image_viewer.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +class FullScreenImageViewer extends StatelessWidget { + final String imageUrl; + + const FullScreenImageViewer({ + super.key, + required this.imageUrl, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ), + body: PhotoView( + imageProvider: CachedNetworkImageProvider(imageUrl), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + backgroundDecoration: const BoxDecoration(color: Colors.black), + ), + ); + } +} diff --git a/app/lib/pages/apps/widgets/show_app_options_sheet.dart b/app/lib/pages/apps/widgets/show_app_options_sheet.dart index fb5f8807d..6696ab642 100644 --- a/app/lib/pages/apps/widgets/show_app_options_sheet.dart +++ b/app/lib/pages/apps/widgets/show_app_options_sheet.dart @@ -96,6 +96,7 @@ class ShowAppOptionsSheet extends StatelessWidget { title: const Text('Update App Details'), leading: const Icon(Icons.edit), onTap: () { + Navigator.pop(context); routeToPage(context, UpdateAppPage(app: app)); }, ), diff --git a/backend/models/app.py b/backend/models/app.py index c8d82b1ff..d8928a247 100644 --- a/backend/models/app.py +++ b/backend/models/app.py @@ -82,6 +82,8 @@ class App(BaseModel): payment_link_id: Optional[str] = None payment_link: Optional[str] = None is_user_paid: Optional[bool] = False + thumbnails: Optional[List[str]] = [] # List of thumbnail IDs + thumbnail_urls: Optional[List[str]] = [] # List of thumbnail URLs def get_rating_avg(self) -> Optional[str]: return f'{self.rating_avg:.1f}' if self.rating_avg is not None else None diff --git a/backend/routers/apps.py b/backend/routers/apps.py index 83d9e6e58..bab2ddd08 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -4,7 +4,7 @@ from typing import List import requests from ulid import ULID -from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException, Header +from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException, Header, Response from database.apps import change_app_approval_status, get_unapproved_public_apps_db, \ add_app_to_db, update_app_in_db, delete_app_from_db, update_app_visibility_in_db, \ @@ -22,7 +22,7 @@ from utils.notifications import send_notification from utils.other import endpoints as auth from models.app import App -from utils.other.storage import upload_plugin_logo, delete_plugin_logo +from utils.other.storage import upload_plugin_logo, delete_plugin_logo, upload_app_thumbnail, get_app_thumbnail_url from utils.stripe import is_onboarding_complete router = APIRouter() @@ -86,7 +86,7 @@ def create_app(app_data: str = Form(...), file: UploadFile = File(...), uid=Depe app = App(**data) upsert_app_payment_link(app.id, app.is_paid, app.price, app.payment_plan, app.uid) - return {'status': 'ok'} + return {'status': 'ok', 'app_id': app.id} @router.patch('/v1/apps/{app_id}', tags=['v1']) @@ -153,6 +153,13 @@ def get_app_details(app_id: str, uid: str = Depends(auth.get_current_user_uid)): if app.payment_link: app.payment_link = f'{app.payment_link}?client_reference_id=uid_{uid}' + # Generate thumbnail URLs if thumbnails exist + if app.thumbnails: + app.thumbnail_urls = [ + get_app_thumbnail_url(thumbnail_id) + for thumbnail_id in app.thumbnails + ] + return app @@ -448,6 +455,43 @@ def reject_app(app_id: str, uid: str, secret_key: str = Header(...)): @router.delete('/v1/personas/{persona_id}', tags=['v1']) +@router.post('/v1/app/thumbnails', tags=['v1']) +async def upload_app_thumbnail_endpoint( + file: UploadFile = File(...), + uid: str = Depends(auth.get_current_user_uid) +): + """Upload a thumbnail image for an app. + + Args: + file: The thumbnail image file + app_id: ID of the app to add thumbnail for + uid: User ID from auth + + Returns: + Dict with thumbnail URL + """ + # Save uploaded file temporarily + thumbnail_id = str(ULID()) + os.makedirs('_temp/thumbnails', exist_ok=True) + temp_path = f'_temp/thumbnails/{thumbnail_id}.jpg' + + try: + with open(temp_path, 'wb') as f: + f.write(await file.read()) + + # Upload to cloud storage + url = upload_app_thumbnail(temp_path, thumbnail_id) + + return { + 'thumbnail_url': url, + 'thumbnail_id': thumbnail_id + } + + finally: + # Cleanup temp file + if os.path.exists(temp_path): + os.remove(temp_path) + def delete_persona(persona_id: 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') diff --git a/backend/utils/other/storage.py b/backend/utils/other/storage.py index 16d3bc68f..b7f223725 100644 --- a/backend/utils/other/storage.py +++ b/backend/utils/other/storage.py @@ -20,6 +20,7 @@ memories_recordings_bucket = os.getenv('BUCKET_MEMORIES_RECORDINGS') syncing_local_bucket = os.getenv('BUCKET_TEMPORAL_SYNC_LOCAL') omi_plugins_bucket = os.getenv('BUCKET_PLUGINS_LOGOS') +app_thumbnails_bucket = os.getenv('BUCKET_APP_THUMBNAILS') # ******************************************* @@ -250,4 +251,15 @@ def delete_plugin_logo(img_url: str): path = img_url.split(f'https://storage.googleapis.com/{omi_plugins_bucket}/')[1] print('delete_plugin_logo', path) blob = bucket.blob(path) - blob.delete() \ No newline at end of file + blob.delete() + +def upload_app_thumbnail(file_path: str, thumbnail_id: str) -> str: + bucket = storage_client.bucket(app_thumbnails_bucket) + path = f'{thumbnail_id}.jpg' + blob = bucket.blob(path) + blob.upload_from_filename(file_path) + return f'https://storage.googleapis.com/{app_thumbnails_bucket}/{path}' + +def get_app_thumbnail_url(thumbnail_id: str) -> str: + path = f'{thumbnail_id}.jpg' + return f'https://storage.googleapis.com/{app_thumbnails_bucket}/{path}'