diff --git a/lib/localPreference/local_preference.dart b/lib/localPreference/local_preference.dart index 7601c08..5d4f90a 100644 --- a/lib/localPreference/local_preference.dart +++ b/lib/localPreference/local_preference.dart @@ -182,6 +182,22 @@ class LocalPreference { return null; } + /// Clear only access token (keep refresh token) + static Future clearAccessToken() async { + final db = await LocalDatabase().database; + + await db.update( + 'user_tokens', + {'access_token': ''}, // โ† empty string, not null + where: 'id = ?', + whereArgs: [1], + ); + + if (kDebugMode) { + print('๐Ÿงน [LOCAL_PREF] Access token cleared'); + } + } + /// Get refresh token static Future getRefreshToken() async { final db = await LocalDatabase().database; diff --git a/lib/networkApiServices/network_api_services.dart b/lib/networkApiServices/network_api_services.dart index 10a65ab..c3b0fc9 100644 --- a/lib/networkApiServices/network_api_services.dart +++ b/lib/networkApiServices/network_api_services.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../localPreference/local_preference.dart'; @@ -7,16 +5,16 @@ import '../networkApiServices/api_urls.dart'; class NetworkApiService { static final NetworkApiService _instance = NetworkApiService._internal(); - late Dio _dio; - bool _isRefreshing = false; - final List _retryQueue = []; + late Dio _dio; + late Dio _tokenDio; // โœ… Separate Dio for token refresh (no interceptors) factory NetworkApiService() { return _instance; } NetworkApiService._internal() { + // ================= MAIN DIO ================= _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 60), @@ -28,7 +26,19 @@ class NetworkApiService { ), ); - // ================= RETRY INTERCEPTOR ================= + // ================= TOKEN DIO (No interceptors โ€” used only for refresh) ================= + _tokenDio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + // ================= 1. RETRY INTERCEPTOR ================= _dio.interceptors.add( InterceptorsWrapper( onError: (err, handler) async { @@ -38,19 +48,17 @@ class NetworkApiService { final shouldRetry = currentRetry < maxRetries && - (err.type == DioExceptionType.connectionTimeout || - err.type == DioExceptionType.sendTimeout || - err.type == DioExceptionType.receiveTimeout); + (err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.sendTimeout || + err.type == DioExceptionType.receiveTimeout); if (shouldRetry) { if (kDebugMode) { print( - '๐Ÿ” Retrying request (${currentRetry + 1}) => ${options.uri}', + '๐Ÿ” Retrying request (${currentRetry + 1}/$maxRetries) => ${options.uri}', ); } - options.extra['retry'] = currentRetry + 1; - try { final response = await _dio.fetch(options); return handler.resolve(response); @@ -59,36 +67,38 @@ class NetworkApiService { } } - return handler.reject(err); + return handler.next(err); }, ), ); - // ================= MAIN INTERCEPTOR ================= - // Use Dio's built-in QueuedInterceptor for better concurrency control + // ================= 2. MAIN INTERCEPTOR (Auth + Token Refresh) ================= _dio.interceptors.add( QueuedInterceptorsWrapper( onRequest: (options, handler) async { final token = await LocalPreference.getAccessToken(); - if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, + onResponse: (response, handler) { + handler.next(response); + }, + onError: (error, handler) async { if (error.response?.statusCode == 401) { final requestOptions = error.requestOptions; try { - // QueuedInterceptor handles concurrency automatically final refreshed = await _refreshToken(); if (refreshed) { final newToken = await LocalPreference.getAccessToken(); requestOptions.headers['Authorization'] = 'Bearer $newToken'; + // โœ… Retry original request with new token final response = await _dio.fetch(requestOptions); return handler.resolve(response); } else { @@ -96,6 +106,7 @@ class NetworkApiService { return handler.reject(error); } } catch (e) { + if (kDebugMode) print('โŒ Error during token refresh flow: $e'); await _forceLogout(); return handler.reject(error); } @@ -106,7 +117,7 @@ class NetworkApiService { ), ); - // ================= LOGGING INTERCEPTOR ================= + // ================= 3. LOGGING INTERCEPTOR ================= if (kDebugMode) { _dio.interceptors.add( LogInterceptor( @@ -114,7 +125,9 @@ class NetworkApiService { requestHeader: true, requestBody: true, responseBody: true, + responseHeader: false, error: true, + logPrint: (log) => print('๐Ÿ“ก $log'), ), ); } @@ -162,7 +175,7 @@ class NetworkApiService { } } - // ================= PUT (NEW) ================= + // ================= PUT ================= Future putApi({ required String url, dynamic data, @@ -207,33 +220,76 @@ class NetworkApiService { } // ================= REFRESH TOKEN ================= + // โœ… Uses _tokenDio (no interceptors) to avoid QueuedInterceptor deadlock Future _refreshToken() async { try { final refreshToken = await LocalPreference.getRefreshToken(); - if (refreshToken == null) return false; - final response = await _dio.post( + if (kDebugMode) print('๐Ÿ” Refresh token from storage: $refreshToken'); + + if (refreshToken == null || refreshToken.isEmpty) { + if (kDebugMode) print('โŒ No refresh token found'); + return false; + } + + if (kDebugMode) print('๐Ÿ”„ Attempting token refresh...'); + + final response = await _tokenDio.post( ApiUrls.refreshToken, - data: {"refreshToken": refreshToken}, - options: Options(headers: {'Authorization': null}), + data: '', // โœ… Empty body โ€” server reads token from Cookie header + options: Options( + headers: { + // โœ… Manually inject refresh token as cookie header + 'Cookie': 'refresh_token=$refreshToken', + }, + validateStatus: (status) => status != null && status < 500, + ), ); - await LocalPreference.setAccessToken(response.data['accessToken']); + if (kDebugMode) { + print("๐Ÿ”„ Refresh response status: ${response.statusCode}"); + print("โœ… REFRESH RESPONSE => ${response.data}"); + + // โœ… ADD THESE to see full response details + print("๐Ÿ“‹ Response Headers: ${response.headers.map}"); + print("๐Ÿ“‹ Full Response Data: ${response.data.toString()}"); + print("๐Ÿ“‹ Request Headers Sent: ${response.requestOptions.headers}"); // โ† This shows what was actually sent + } + + if (response.statusCode != 200 && response.statusCode != 201) { + if (kDebugMode) print('โŒ Refresh failed with status: ${response.statusCode}'); + return false; + } + + final newAccessToken = response.data['accessToken']; + + if (newAccessToken == null || (newAccessToken as String).isEmpty) { + if (kDebugMode) print('โŒ Access token missing in refresh response'); + return false; + } + + await LocalPreference.setAccessToken(newAccessToken); + if (kDebugMode) print('โœ… Token refreshed successfully'); return true; - } catch (_) { + + } on DioException catch (e) { + if (kDebugMode) print('โŒ Refresh token DioException: ${e.message}'); + return false; + } catch (e) { + if (kDebugMode) print('โŒ Refresh token unexpected error: $e'); return false; } } - // ================= LOGOUT ================= + // ================= FORCE LOGOUT ================= Future _forceLogout() async { + if (kDebugMode) print('๐Ÿšช Force logout triggered'); await LocalPreference.clearTokens(); await LocalPreference.setLogin(false); await LocalPreference.resetOnboarding(); // TODO: navigate to login screen } - // ================= ERROR HANDLER ================= // ================= ERROR HANDLER ================= String _handleError(DioException error) { switch (error.type) { @@ -246,27 +302,20 @@ class NetworkApiService { case DioExceptionType.badCertificate: return "Bad certificate."; case DioExceptionType.badResponse: - // ๐Ÿ”ฅ FIXED: Safely handle different response data types try { final responseData = error.response?.data; - - // If it's a Map, try to get the message if (responseData is Map) { return responseData['message'] ?? responseData['error'] ?? "Invalid status code: ${error.response?.statusCode}"; } - - // If it's a String, return it directly if (responseData is String) { return responseData.isNotEmpty ? responseData : "Invalid status code: ${error.response?.statusCode}"; } - - // For any other type, return generic error return "Invalid status code: ${error.response?.statusCode}"; - } catch (e) { + } catch (_) { return "Invalid status code: ${error.response?.statusCode}"; } case DioExceptionType.cancel: @@ -282,4 +331,4 @@ class NetworkApiService { void updateHeaders(Map headers) { _dio.options.headers.addAll(headers); } -} +} \ No newline at end of file diff --git a/lib/postcard/blocs/edit_image_filter/edit_image_filter_bloc.dart b/lib/postcard/blocs/edit_image_filter/edit_image_filter_bloc.dart index cbb2642..a44434a 100644 --- a/lib/postcard/blocs/edit_image_filter/edit_image_filter_bloc.dart +++ b/lib/postcard/blocs/edit_image_filter/edit_image_filter_bloc.dart @@ -1,9 +1,10 @@ import 'dart:developer'; import 'dart:io'; - +import 'dart:typed_data'; import 'package:bloc/bloc.dart'; import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; // For compute import 'package:path_provider/path_provider.dart'; import 'package:image/image.dart' as img; @@ -12,196 +13,118 @@ part 'edit_image_filter_state.dart'; enum EditImageType { network, file } -class EditImageFilterBloc - extends Bloc { - - // โœ… OPTIMIZATION: Cache decoded image in memory - img.Image? _cachedDecodedImage; - String? _cachedImagePath; - - // โœ… OPTIMIZATION: Pre-processed filter cache - final Map _filterCache = {}; - +class EditImageFilterBloc extends Bloc { EditImageFilterBloc() : super(EditImageFilterInitial()) { on((event, emit) async { try { emit(DownloadImageLoading()); + String initialFilePath = event.url; + if (event.type == EditImageType.network) { - final Dio dio = Dio(); - final directory = await getTemporaryDirectory(); - - final String fileName = - '${DateTime.now().millisecondsSinceEpoch}.png'; - - final String filePath = '${directory.path}/$fileName'; - - await dio.download( - event.url, - filePath, - options: Options(responseType: ResponseType.bytes), - ); - - // โœ… Clear cache when new image is downloaded - _filterCache.clear(); - _cachedDecodedImage = null; - _cachedImagePath = null; - - emit( - DownloadImageSuccessfully( - filePath: filePath, - filteredImagePath: filePath, - filter: 'original', - ), - ); - } else { - // โœ… Clear cache when new image is loaded - _filterCache.clear(); - _cachedDecodedImage = null; - _cachedImagePath = null; - - emit( - DownloadImageSuccessfully( - filePath: event.url, - filteredImagePath: event.url, - filter: 'original', - ), - ); + initialFilePath = '${directory.path}/${DateTime.now().millisecondsSinceEpoch}.png'; + await Dio().download(event.url, initialFilePath); } + + emit(DownloadImageSuccessfully( + originalFilePath: initialFilePath, + currentSelectedFilter: 'original', + finalFilteredImagePath: initialFilePath, + isProcessingSave: false, + )); } catch (e) { + log("Download error: $e"); emit(DownloadImageFailed()); } }); on((event, emit) async { if (state is! DownloadImageSuccessfully) return; - final currentState = state as DownloadImageSuccessfully; + // Instantly update the UI for visual feedback using ColorFilter + emit(currentState.copyWith( + currentSelectedFilter: event.filterName, + )); + }); + + on((event, emit) async { + if (state is! DownloadImageSuccessfully) return; + final currentState = state as DownloadImageSuccessfully; + + emit(currentState.copyWith(isProcessingSave: true)); + try { - log("Selected Filter ${event.filterName}"); - - // 1๏ธโƒฃ Handle "Original" immediately (instant) - if (event.filterName == "none" || event.filterName == "original") { - emit( - currentState.copyWith( - filteredImagePath: currentState.filePath, - filter: "original", - ), - ); + if (currentState.currentSelectedFilter == "original" || currentState.currentSelectedFilter == "none") { + // If original filter is selected, no processing needed, just use the original path + emit(currentState.copyWith( + finalFilteredImagePath: currentState.originalFilePath, + isProcessingSave: false, + )); return; } - // 2๏ธโƒฃ Check if filter is already cached - if (_filterCache.containsKey(event.filterName)) { - log("โœ… Using cached filter: ${event.filterName}"); - emit( - currentState.copyWith( - filteredImagePath: _filterCache[event.filterName], - filter: event.filterName, - ), - ); - return; - } + final String tempDir = (await getTemporaryDirectory()).path; + final Map params = { + 'originalFilePath': currentState.originalFilePath, + 'filterName': currentState.currentSelectedFilter, + 'tempDir': tempDir, + }; - // 3๏ธโƒฃ Emit ColorFilter preview IMMEDIATELY - log("๐ŸŽจ Showing ColorFilter preview for: ${event.filterName}"); - emit( - currentState.copyWith( - filter: event.filterName, - ), - ); + final String generatedFilePath = await compute(_processImageInIsolate, params); - // 4๏ธโƒฃ โœ… WAIT FOR BACKGROUND PROCESSING TO COMPLETE - // This ensures the cached file is ready before returning - log("โณ Processing filter in background..."); - await _processFilterInBackground(event.filterName, currentState); - - // 5๏ธโƒฃ โœ… After processing, emit with the cached file - if (_filterCache.containsKey(event.filterName)) { - log("โœ… Filter processed! Updating UI with cached file."); - emit( - currentState.copyWith( - filteredImagePath: _filterCache[event.filterName], - filter: event.filterName, - ), - ); - } + emit(currentState.copyWith( + finalFilteredImagePath: generatedFilePath, + isProcessingSave: false, + )); } catch (e) { - log("SelectFilter error: ${e.toString()}"); + log("ProcessAndSaveImage error: $e"); + emit(currentState.copyWith(isProcessingSave: false)); } }); } +} - // โœ… Background filter processing (doesn't block initial UI update) - Future _processFilterInBackground( - String filterName, - DownloadImageSuccessfully currentState, - ) async { - try { - // Decode image only once and cache it - if (_cachedImagePath != currentState.filePath) { - final originalFile = File(currentState.filePath); - final bytes = await originalFile.readAsBytes(); - _cachedDecodedImage = img.decodeImage(bytes); - _cachedImagePath = currentState.filePath; - } +// Top-level function to run in a separate Isolate +Future _processImageInIsolate(Map params) async { + final String originalFilePath = params['originalFilePath'] as String; + final String filterName = params['filterName'] as String; + final String tempDir = params['tempDir'] as String; - if (_cachedDecodedImage == null) { - log("โŒ Failed to decode image"); - return; - } + final File originalFile = File(originalFilePath); + final Uint8List bytes = await originalFile.readAsBytes(); - // Clone the cached image for processing - img.Image? processedImage = _cachedDecodedImage!.clone(); - - // Apply filter - switch (filterName) { - case "vintage": - processedImage = img.adjustColor( - processedImage, - saturation: 0.8, - gamma: 1.1, - contrast: 0.9, - ); - break; - case "bw": - processedImage = img.grayscale(processedImage); - break; - case "sepia": - processedImage = img.sepia(processedImage); - break; - case "cool": - processedImage = img.adjustColor(processedImage, hue: -0.042, contrast: 1.05); - break; - case "contrast": - processedImage = img.adjustColor(processedImage, contrast: 1.4); - break; - case "soft": - processedImage = img.adjustColor( - processedImage, - brightness: 0.1, - gamma: 0.9, - saturation: 1.1, - ); - break; - default: - return; - } - - // Save to cache - final originalFile = File(currentState.filePath); - final filteredPath = - "${originalFile.parent.path}/filtered_${filterName}_${DateTime.now().millisecondsSinceEpoch}.jpg"; - - final filteredFile = File(filteredPath) - ..writeAsBytesSync(img.encodeJpg(processedImage, quality: 95)); - - _filterCache[filterName] = filteredFile.path; - - log("โœ… Filter '$filterName' processed and cached"); - } catch (e) { - log("โŒ Error processing filter: $e"); - } + img.Image? image = img.decodeImage(bytes); + if (image == null) { + throw Exception("Failed to decode image in Isolate"); } + + img.Image filteredImage = image.clone(); // Clone to avoid modifying original + + switch (filterName) { + case "vintage": + filteredImage = img.adjustColor(filteredImage, saturation: 0.8, gamma: 1.1, contrast: 0.9); + break; + case "bw": + filteredImage = img.grayscale(filteredImage); + break; + case "sepia": + filteredImage = img.sepia(filteredImage); + break; + case "cool": + filteredImage = img.adjustColor(filteredImage, hue: -0.042, contrast: 1.05); + break; + case "contrast": + filteredImage = img.adjustColor(filteredImage, contrast: 1.4); + break; + case "soft": + filteredImage = img.adjustColor(filteredImage, brightness: 0.1, gamma: 0.9, saturation: 1.1); + break; + } + + final String outputPath = "$tempDir/filtered_${filterName}_${DateTime.now().millisecondsSinceEpoch}.jpg"; + final encodedBytes = img.encodeJpg(filteredImage, quality: 90); + await File(outputPath).writeAsBytes(encodedBytes); + + return outputPath; } \ No newline at end of file diff --git a/lib/postcard/blocs/edit_image_filter/edit_image_filter_event.dart b/lib/postcard/blocs/edit_image_filter/edit_image_filter_event.dart index 69b2e02..7d44bdb 100644 --- a/lib/postcard/blocs/edit_image_filter/edit_image_filter_event.dart +++ b/lib/postcard/blocs/edit_image_filter/edit_image_filter_event.dart @@ -1,6 +1,6 @@ part of 'edit_image_filter_bloc.dart'; -class EditImageFilterEvent extends Equatable { +abstract class EditImageFilterEvent extends Equatable { const EditImageFilterEvent(); @override @@ -10,10 +10,30 @@ class EditImageFilterEvent extends Equatable { class DownloadImage extends EditImageFilterEvent { final String url; final EditImageType type; - const DownloadImage({required this.url, required this.type}); + + const DownloadImage({ + required this.url, + required this.type, + }); + + @override + List get props => [url, type]; } class SelectFilter extends EditImageFilterEvent { final String filterName; - const SelectFilter({required this.filterName}); + + const SelectFilter({ + required this.filterName, + }); + + @override + List get props => [filterName]; +} + +class ProcessAndSaveImage extends EditImageFilterEvent { + const ProcessAndSaveImage(); + + @override + List get props => []; } diff --git a/lib/postcard/blocs/edit_image_filter/edit_image_filter_state.dart b/lib/postcard/blocs/edit_image_filter/edit_image_filter_state.dart index eca9d59..dd41ca9 100644 --- a/lib/postcard/blocs/edit_image_filter/edit_image_filter_state.dart +++ b/lib/postcard/blocs/edit_image_filter/edit_image_filter_state.dart @@ -12,31 +12,37 @@ class EditImageFilterInitial extends EditImageFilterState {} class DownloadImageLoading extends EditImageFilterState {} class DownloadImageSuccessfully extends EditImageFilterState { - final String filePath; - final String filteredImagePath; - final bool processing; - final String filter; + final String originalFilePath; // Path to the original un-filtered image + final String currentSelectedFilter; // The filter currently applied visually + final String finalFilteredImagePath; // Path to the actually processed image file (after save) + final bool isProcessingSave; // Indicates if the save operation is in progress const DownloadImageSuccessfully({ - required this.filePath, - required this.filteredImagePath, - this.processing = false, - required this.filter, + required this.originalFilePath, + required this.currentSelectedFilter, + required this.finalFilteredImagePath, + this.isProcessingSave = false, }); + @override - List get props => [filePath, filteredImagePath, processing, filter]; + List get props => [ + originalFilePath, + currentSelectedFilter, + finalFilteredImagePath, + isProcessingSave, + ]; DownloadImageSuccessfully copyWith({ - String? filePath, - String? filteredImagePath, - bool? processing, - String? filter, + String? originalFilePath, + String? currentSelectedFilter, + String? finalFilteredImagePath, + bool? isProcessingSave, }) { return DownloadImageSuccessfully( - filePath: filePath ?? this.filePath, - filteredImagePath: filteredImagePath ?? this.filteredImagePath, - processing: processing ?? this.processing, - filter: filter ?? this.filter, + originalFilePath: originalFilePath ?? this.originalFilePath, + currentSelectedFilter: currentSelectedFilter ?? this.currentSelectedFilter, + finalFilteredImagePath: finalFilteredImagePath ?? this.finalFilteredImagePath, + isProcessingSave: isProcessingSave ?? this.isProcessingSave, ); } } diff --git a/lib/postcard/blocs/postcard_creation_bloc.dart b/lib/postcard/blocs/postcard_creation_bloc.dart index c091b44..9f778e4 100644 --- a/lib/postcard/blocs/postcard_creation_bloc.dart +++ b/lib/postcard/blocs/postcard_creation_bloc.dart @@ -1,63 +1,71 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:citycards_customer/postcard/blocs/postcard_creation_events.dart'; import 'package:citycards_customer/postcard/blocs/postcard_creation_state.dart'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image/image.dart' as img; +import 'package:path_provider/path_provider.dart'; -enum PostcardStep { uploadPhoto, addFilter, writeMessage, preview, purchase, checkout, orderSuccess, myOrders, myOrderPostcardPreview} +enum PostcardStep { + uploadPhoto, + addFilter, + writeMessage, + preview, + purchase, + checkout, + orderSuccess, + myOrders, + myOrderPostcardPreview, +} class PostcardCreationBloc extends Bloc { final ImagePicker _picker = ImagePicker(); - // โœ… OPTIMIZATION: Cache decoded image in memory - img.Image? _cachedDecodedImage; - String? _cachedImagePath; - - // โœ… OPTIMIZATION: Pre-processed filter cache - final Map _filterCache = {}; - - static const int maxImageSizeInBytes = 5 * 1024 * 1024; // 10 MB + static const int maxImageSizeInBytes = 5 * 1024 * 1024; // 5 MB PostcardCreationBloc() : super( - const PostcardCreationState(currentStep: PostcardStep.uploadPhoto, address: ''), + const PostcardCreationState( + currentStep: PostcardStep.uploadPhoto, + address: '', + ), ) { + /* โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ - /* Navigation steps */ on((event, emit) async { - if (state.currentStep == PostcardStep.uploadPhoto && state.imagePath != null) { + if (state.currentStep == PostcardStep.uploadPhoto && + state.imagePath != null) { final file = File(state.imagePath!); final fileSize = await file.length(); if (fileSize > maxImageSizeInBytes) { final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); emit(state.copyWith( - errorMessage: "Image size is too large ($sizeInMB MB). Maximum allowed size is 10 MB.", + errorMessage: + "Image size is too large ($sizeInMB MB). Maximum allowed size is 10 MB.", )); return; } } - final next = PostcardStep.values[(state.currentStep.index + 1).clamp( - 0, - PostcardStep.values.length - 1, - )]; + final next = PostcardStep.values[ + (state.currentStep.index + 1) + .clamp(0, PostcardStep.values.length - 1)]; emit(state.copyWith(currentStep: next, errorMessage: null)); }); - /* Go to previous step */ on((event, emit) { - final prev = PostcardStep.values[(state.currentStep.index - 1).clamp( - 0, - PostcardStep.values.length - 1, - )]; + final prev = PostcardStep.values[ + (state.currentStep.index - 1) + .clamp(0, PostcardStep.values.length - 1)]; emit(state.copyWith(currentStep: prev, errorMessage: null)); }); - /* Upload image */ + /* โ”€โ”€ Image picking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + on((event, emit) async { final file = File(event.imagePath); final fileSize = await file.length(); @@ -65,89 +73,167 @@ class PostcardCreationBloc if (fileSize > maxImageSizeInBytes) { final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); emit(state.copyWith( - errorMessage: "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB.", + errorMessage: + "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB.", )); return; } - // โœ… OPTIMIZATION: Clear filter cache when new image is uploaded - _filterCache.clear(); - _cachedDecodedImage = null; - _cachedImagePath = null; - - emit( - state.copyWith( - imagePath: event.imagePath, - originalImagePath: event.imagePath, - errorMessage: null, - ), - ); + emit(state.copyWith( + imagePath: event.imagePath, + originalImagePath: event.imagePath, + filter: 'original', + filterProcessingDone: false, + isProcessingSave: false, + errorMessage: null, + )); }); - /* Pick image from gallery */ on((event, emit) async { final pickedFile = await _picker.pickImage(source: ImageSource.gallery); - if (pickedFile != null) { - final file = File(pickedFile.path); - final fileSize = await file.length(); + if (pickedFile == null) return; - if (fileSize > maxImageSizeInBytes) { - final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); - emit(state.copyWith( - errorMessage: "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB. Please select a smaller image.", - )); - return; - } + final file = File(pickedFile.path); + final fileSize = await file.length(); - // โœ… OPTIMIZATION: Clear cache - _filterCache.clear(); - _cachedDecodedImage = null; - _cachedImagePath = null; - - emit( - state.copyWith( - imagePath: pickedFile.path, - originalImagePath: pickedFile.path, - errorMessage: null, - ), - ); + if (fileSize > maxImageSizeInBytes) { + final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); + emit(state.copyWith( + errorMessage: + "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB. Please select a smaller image.", + )); + return; } + + emit(state.copyWith( + imagePath: pickedFile.path, + originalImagePath: pickedFile.path, + filter: 'original', + filterProcessingDone: false, + isProcessingSave: false, + errorMessage: null, + )); }); - /* Pick image from camera */ on((event, emit) async { final pickedFile = await _picker.pickImage(source: ImageSource.camera); - if (pickedFile != null) { - final file = File(pickedFile.path); - final fileSize = await file.length(); + if (pickedFile == null) return; - if (fileSize > maxImageSizeInBytes) { - final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); - emit(state.copyWith( - errorMessage: "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB. Please try taking a photo with lower quality.", - )); - return; - } + final file = File(pickedFile.path); + final fileSize = await file.length(); - // โœ… OPTIMIZATION: Clear cache - _filterCache.clear(); - _cachedDecodedImage = null; - _cachedImagePath = null; - - emit( - state.copyWith( - imagePath: pickedFile.path, - originalImagePath: pickedFile.path, - errorMessage: null, - ), - ); + if (fileSize > maxImageSizeInBytes) { + final sizeInMB = (fileSize / (1024 * 1024)).toStringAsFixed(2); + emit(state.copyWith( + errorMessage: + "Image size is too large ($sizeInMB MB). Maximum allowed size is 5 MB. Please try taking a photo with lower quality.", + )); + return; } + + emit(state.copyWith( + imagePath: pickedFile.path, + originalImagePath: pickedFile.path, + filter: 'original', + filterProcessingDone: false, + isProcessingSave: false, + errorMessage: null, + )); }); on((event, emit) { emit(state.copyWith(errorMessage: null)); }); + /* โ”€โ”€ SelectFilter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + /// + /// EXACT mirror of [EditImageFilterBloc.on]: + /// Only updates [filter] in state. The UI immediately shows the + /// ColorFilter widget preview โ€” zero processing, zero delay. + on((event, emit) { + if (state.originalImagePath == null) return; + + emit(state.copyWith( + filter: event.filterName, + filterProcessingDone: false, + )); + }); + + /* โ”€โ”€ ProcessSelectedFilter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + /// + /// EXACT mirror of [EditImageFilterBloc.on]: + /// 1. emit isProcessingSave = true โ†’ button shows spinner + /// 2. Run heavy work in isolate via compute() + /// 3. emit imagePath = processed file path + /// 4. emit filterProcessingDone = true โ†’ BlocConsumer navigates + on((event, emit) async { + if (state.originalImagePath == null) return; + + final selectedFilter = state.filter ?? 'original'; + + // Original / none โ€” no processing needed, navigate right away + if (selectedFilter == 'original' || selectedFilter == 'none') { + emit(state.copyWith( + imagePath: state.originalImagePath, + isProcessingSave: false, + filterProcessingDone: true, + )); + return; + } + + // Step 1 โ€” show spinner on button + emit(state.copyWith( + isProcessingSave: true, + filterProcessingDone: false, + )); + + try { + // Step 2 โ€” process in isolate (same as EditImageFilterBloc) + final String tempDir = (await getTemporaryDirectory()).path; + final Map params = { + 'originalFilePath': state.originalImagePath!, + 'filterName': selectedFilter, + 'tempDir': tempDir, + }; + + final String generatedFilePath = + await compute(_processImageInIsolate, params); + + // Step 3 & 4 โ€” done, hide spinner, signal navigation + emit(state.copyWith( + imagePath: generatedFilePath, + isProcessingSave: false, + filterProcessingDone: true, + )); + } catch (e) { + debugPrint("ProcessSelectedFilter error: $e"); + // On failure still navigate โ€” don't leave user stuck + emit(state.copyWith( + imagePath: state.originalImagePath, + isProcessingSave: false, + filterProcessingDone: true, + )); + } + }); + + /* โ”€โ”€ Other events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + on((event, emit) { + emit(state.copyWith(message: event.message)); + }); + + on((event, emit) { + emit(state.copyWith(selectedFont: event.fontName)); + }); + + on((event, emit) { + emit(state.copyWith(pcNumber: event.pcNumber)); + }); + + on((event, emit) { + emit(state.copyWith(isGift: event.isGift)); + }); + on((event, emit) { emit(state.copyWith( pcTitle: event.pcTitle, @@ -165,75 +251,6 @@ class PostcardCreationBloc )); }); - /* โœ… OPTIMIZED: Select filter - Single click now works! */ - on((event, emit) async { - if (state.originalImagePath == null) return; - - // 1๏ธโƒฃ Handle "Original" immediately (instant) - if (event.filterName == "none" || event.filterName == "original") { - emit( - state.copyWith( - imagePath: state.originalImagePath, - filter: "none", - isProcessing: false, - ), - ); - return; - } - - // 2๏ธโƒฃ Check if filter is already cached - if (_filterCache.containsKey(event.filterName)) { - debugPrint("โœ… Using cached filter: ${event.filterName}"); - emit( - state.copyWith( - imagePath: _filterCache[event.filterName], - filter: event.filterName, - isProcessing: false, - ), - ); - return; - } - - // 3๏ธโƒฃ Emit ColorFilter preview IMMEDIATELY - debugPrint("๐ŸŽจ Showing ColorFilter preview for: ${event.filterName}"); - emit(state.copyWith( - filter: event.filterName, - isProcessing: false, - )); - - // 4๏ธโƒฃ โœ… WAIT FOR BACKGROUND PROCESSING TO COMPLETE - debugPrint("โณ Processing filter in background..."); - await _processFilterInBackground(event.filterName); - - // 5๏ธโƒฃ โœ… After processing, emit with the cached file - if (_filterCache.containsKey(event.filterName)) { - debugPrint("โœ… Filter processed! Updating UI with cached file."); - emit( - state.copyWith( - imagePath: _filterCache[event.filterName], - filter: event.filterName, - isProcessing: false, - ), - ); - } - }); - - on((event, emit) { - emit(state.copyWith(message: event.message)); - }); - - on((event, emit) { - emit(state.copyWith(selectedFont: event.fontName)); - }); - - on((event, emit) { - emit(state.copyWith(pcNumber: event.pcNumber)); - }); - - on((event, emit) { - emit(state.copyWith(isGift: event.isGift)); - }); - on((event, emit) { emit(state.copyWith( userProfileFullName: event.fullName, @@ -249,82 +266,60 @@ class PostcardCreationBloc }); } - // โœ… NEW: Background filter processing (doesn't block initial UI update) - Future _processFilterInBackground(String filterName) async { - try { - // Decode image only once and cache it - if (_cachedImagePath != state.originalImagePath) { - final originalFile = File(state.originalImagePath!); - final bytes = await originalFile.readAsBytes(); - _cachedDecodedImage = img.decodeImage(bytes); - _cachedImagePath = state.originalImagePath; - } - - if (_cachedDecodedImage == null) { - debugPrint("โŒ Failed to decode image"); - return; - } - - // Clone the cached image for processing - img.Image? processedImage = _cachedDecodedImage!.clone(); - - // Apply filter - switch (filterName) { - case "vintage": - processedImage = img.adjustColor( - processedImage, - saturation: 0.8, - gamma: 1.1, - contrast: 0.9, - ); - break; - case "bw": - processedImage = img.grayscale(processedImage); - break; - case "sepia": - processedImage = img.sepia(processedImage); - break; - case "cool": - processedImage = img.adjustColor(processedImage, hue: -15, contrast: 1.05); - break; - case "contrast": - processedImage = img.adjustColor(processedImage, contrast: 1.4); - break; - case "soft": - processedImage = img.adjustColor( - processedImage, - brightness: 0.1, - gamma: 0.9, - saturation: 1.1, - ); - break; - default: - return; - } - - // Save to cache - final originalFile = File(state.originalImagePath!); - final filteredFile = File( - "${originalFile.parent.path}/filtered_${filterName}_${DateTime.now().millisecondsSinceEpoch}.jpg", - )..writeAsBytesSync(img.encodeJpg(processedImage, quality: 95)); - - _filterCache[filterName] = filteredFile.path; - - debugPrint("โœ… Filter '$filterName' processed and cached"); - } catch (e) { - debugPrint("โŒ Error processing filter: $e"); - } - } - String getFormattedMessage() { - if (state.message == null || state.message!.isEmpty) { - return ''; - } - + if (state.message == null || state.message!.isEmpty) return ''; if (state.selectedFont == null || state.selectedFont!.isEmpty) { return '${state.message}'; } - return '${state.message}'; } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Top-level isolate function โ€” identical to the one in edit_image_filter_bloc.dart +// MUST be top-level so compute() can spawn it in a separate isolate. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Future _processImageInIsolate(Map params) async { + final String originalFilePath = params['originalFilePath'] as String; + final String filterName = params['filterName'] as String; + final String tempDir = params['tempDir'] as String; + + final File originalFile = File(originalFilePath); + final Uint8List bytes = await originalFile.readAsBytes(); + + img.Image? image = img.decodeImage(bytes); + if (image == null) throw Exception("Failed to decode image in Isolate"); + + img.Image filteredImage = image.clone(); + + switch (filterName) { + case "vintage": + filteredImage = img.adjustColor(filteredImage, + saturation: 0.8, gamma: 1.1, contrast: 0.9); + break; + case "bw": + filteredImage = img.grayscale(filteredImage); + break; + case "sepia": + filteredImage = img.sepia(filteredImage); + break; + case "cool": + filteredImage = img.adjustColor(filteredImage, + hue: -0.042, contrast: 1.05); + break; + case "contrast": + filteredImage = img.adjustColor(filteredImage, contrast: 1.4); + break; + case "soft": + filteredImage = img.adjustColor(filteredImage, + brightness: 0.1, gamma: 0.9, saturation: 1.1); + break; + } + + final String outputPath = + "$tempDir/filtered_${filterName}_${DateTime.now().millisecondsSinceEpoch}.jpg"; + final encodedBytes = img.encodeJpg(filteredImage, quality: 90); + await File(outputPath).writeAsBytes(encodedBytes); + + return outputPath; } \ No newline at end of file diff --git a/lib/postcard/blocs/postcard_creation_events.dart b/lib/postcard/blocs/postcard_creation_events.dart index 8924947..ffec857 100644 --- a/lib/postcard/blocs/postcard_creation_events.dart +++ b/lib/postcard/blocs/postcard_creation_events.dart @@ -20,6 +20,11 @@ class SelectFilter extends PostcardCreationEvent { SelectFilter(this.filterName); } +/// Triggered when the user taps "Write your message" button. +/// Processes the currently selected filter in the background (if not already cached), +/// shows a loading state, then navigates when done. +class ProcessSelectedFilter extends PostcardCreationEvent {} + class WriteMessage extends PostcardCreationEvent { final String message; @@ -47,7 +52,7 @@ class UpdatePurchaseFormData extends PostcardCreationEvent { final String? country; final String? state; final String? zipCode; - // ๐Ÿ†• Sender fields + // Sender fields final String? senderName; final String? senderCity; final String? senderCountry; @@ -68,16 +73,14 @@ class UpdatePurchaseFormData extends PostcardCreationEvent { }); } -// ๐Ÿ†• NEW: Clear error message class ClearError extends PostcardCreationEvent {} -// ๐Ÿ†• ADD THIS EVENT + class UpdatePostcardNumber extends PostcardCreationEvent { final String pcNumber; UpdatePostcardNumber(this.pcNumber); } -// Event to store user profile data when "Buy for Myself" is selected class StoreUserProfileData extends PostcardCreationEvent { final String? fullName; final String? email; diff --git a/lib/postcard/blocs/postcard_creation_state.dart b/lib/postcard/blocs/postcard_creation_state.dart index d6fbf4e..34503be 100644 --- a/lib/postcard/blocs/postcard_creation_state.dart +++ b/lib/postcard/blocs/postcard_creation_state.dart @@ -7,7 +7,15 @@ class PostcardCreationState { final String? filter; final String? message; final bool isGift; - final bool isProcessing; + + /// True while the "Write your message" button is processing the filter + /// before navigating to the next step. + final bool isProcessingSave; + + /// Flips to true (then immediately back to false via copyWith) to signal + /// the view that processing finished and it should navigate to the next step. + final bool filterProcessingDone; + final String? selectedFont; final String? errorMessage; @@ -33,7 +41,7 @@ class PostcardCreationState { final String? userProfileZipCode; final String? userProfileCountry; - // โœ… Sender fields (for gift mode) + // Sender fields (for gift mode) final String? senderName; final String? senderCity; final String? senderCountry; @@ -45,7 +53,8 @@ class PostcardCreationState { this.filter, this.message, this.isGift = false, - this.isProcessing = false, + this.isProcessingSave = false, + this.filterProcessingDone = false, this.selectedFont, this.errorMessage, this.pcTitle, @@ -59,7 +68,6 @@ class PostcardCreationState { this.zipCode, this.pcNumber, required this.address, - // User profile data this.userProfileFullName, this.userProfileEmail, this.userProfilePhone, @@ -68,7 +76,6 @@ class PostcardCreationState { this.userProfileState, this.userProfileZipCode, this.userProfileCountry, - // Sender data this.senderName, this.senderCity, this.senderCountry, @@ -81,7 +88,8 @@ class PostcardCreationState { String? filter, String? message, bool? isGift, - bool? isProcessing, + bool? isProcessingSave, + bool? filterProcessingDone, String? selectedFont, String? errorMessage, String? pcTitle, @@ -95,7 +103,6 @@ class PostcardCreationState { String? state, String? zipCode, String? pcNumber, - // User profile fields String? userProfileFullName, String? userProfileEmail, String? userProfilePhone, @@ -104,7 +111,6 @@ class PostcardCreationState { String? userProfileState, String? userProfileZipCode, String? userProfileCountry, - // Sender fields String? senderName, String? senderCity, String? senderCountry, @@ -116,7 +122,8 @@ class PostcardCreationState { filter: filter ?? this.filter, message: message ?? this.message, isGift: isGift ?? this.isGift, - isProcessing: isProcessing ?? this.isProcessing, + isProcessingSave: isProcessingSave ?? this.isProcessingSave, + filterProcessingDone: filterProcessingDone ?? this.filterProcessingDone, selectedFont: selectedFont ?? this.selectedFont, errorMessage: errorMessage, pcTitle: pcTitle ?? this.pcTitle, @@ -130,7 +137,6 @@ class PostcardCreationState { state: state ?? this.state, zipCode: zipCode ?? this.zipCode, pcNumber: pcNumber ?? this.pcNumber, - // User profile data userProfileFullName: userProfileFullName ?? this.userProfileFullName, userProfileEmail: userProfileEmail ?? this.userProfileEmail, userProfilePhone: userProfilePhone ?? this.userProfilePhone, @@ -139,7 +145,6 @@ class PostcardCreationState { userProfileState: userProfileState ?? this.userProfileState, userProfileZipCode: userProfileZipCode ?? this.userProfileZipCode, userProfileCountry: userProfileCountry ?? this.userProfileCountry, - // Sender data senderName: senderName ?? this.senderName, senderCity: senderCity ?? this.senderCity, senderCountry: senderCountry ?? this.senderCountry, diff --git a/lib/postcard/views/add_filter_step_page_view.dart b/lib/postcard/views/add_filter_step_page_view.dart index b10b0c1..e4ba313 100644 --- a/lib/postcard/views/add_filter_step_page_view.dart +++ b/lib/postcard/views/add_filter_step_page_view.dart @@ -3,7 +3,6 @@ import 'package:citycards_customer/postcard/widgets/dotted_border_holder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; - import '../../common_packages/app_bar.dart'; import '../blocs/postcard_creation_bloc.dart'; import '../blocs/postcard_creation_events.dart'; @@ -16,168 +15,174 @@ class AddFilterStepPageView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + // โ”€โ”€ Mirrors the listener in EditImageFilter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // EditImageFilter listens for: + // state is DownloadImageSuccessfully && !state.isProcessingSave + // && finalFilteredImagePath != originalFilePath + // Here we listen for filterProcessingDone flipping true โ€” same intent. + listenWhen: (previous, current) => + current.filterProcessingDone && !previous.filterProcessingDone, + listener: (context, state) { + // Processing done โ†’ go to next step (Write your message) + context.read().add(GoToNextStep()); + }, builder: (context, state) { final bloc = context.read(); - - // โœ… FIXED: Always use ORIGINAL image for filter thumbnails + // Always pass the ORIGINAL image to thumbnails โ€” same as EditImageFilter + // which always passes File(state.originalFilePath) to buildFilterOption final imageFile = File(state.originalImagePath!); return SafeArea( - child: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true), - StepProgressBar(totalSteps: 4, currentStep: 2), - GestureDetector( - onTap: () { - context.read().add(GoToPreviousStep()); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - Icon(Icons.arrow_back, size: 20), - const SizedBox(width: 8), - Text( - "Back", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showDivider: true, + ), + StepProgressBar(totalSteps: 4, currentStep: 2), + + // Back button + GestureDetector( + onTap: () => context + .read() + .add(GoToPreviousStep()), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + children: const [ + Icon(Icons.arrow_back, size: 20), + SizedBox(width: 8), + Text( + "Back", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), + ], + ), + ), + ), + + const Text( + "Add a Filter", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + "Choose your favorite filter and enhance your postcard.", + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: const Color(0xff2D3134), + ), + ), + const SizedBox(height: 10), + + // โ”€โ”€ Main image preview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Always shows ORIGINAL file + wraps it in ColorFilter widget + // for instant visual feedback. Same as EditImageFilter which + // passes state.originalFilePath + state.currentSelectedFilter + // to DottedBorderContainerHolder. + if (state.imagePath != null) + DottedBorderContainerHolder( + imagePath: state.originalImagePath!, + filter: state.filter ?? "", + ), + + const SizedBox(height: 20), + + // โ”€โ”€ Filter thumbnails โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // All thumbnails always use originalImagePath โ€” same as + // EditImageFilter passing File(state.originalFilePath) to each + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + buildFilterOption( + context, bloc, "Original", + File(state.originalImagePath!), + "original", state.filter, + ), + buildFilterOption( + context, bloc, "Black & White", + imageFile, "bw", state.filter, + ), + buildFilterOption( + context, bloc, "Sepia", + imageFile, "sepia", state.filter, + ), + buildFilterOption( + context, bloc, "Vintage", + imageFile, "vintage", state.filter, + ), + buildFilterOption( + context, bloc, "Cool Tone", + imageFile, "cool", state.filter, + ), + buildFilterOption( + context, bloc, "Contrast", + imageFile, "contrast", state.filter, + ), + buildFilterOption( + context, bloc, "Soft Glow", + imageFile, "soft", state.filter, + ), + ], + ), + ), + + SizedBox(height: 20.h), + + // โ”€โ”€ "Write your message" button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Mirrors the "Save Changes" button in EditImageFilter: + // โ€ข Grey + spinner while isProcessingSave == true + // โ€ข Dispatches ProcessSelectedFilter (= ProcessAndSaveImage) + // โ€ข Disabled while processing so user can't double-tap + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: state.isProcessingSave + ? Colors.grey + : const Color(0xffF95F62), + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), ), ), - Text( - "Add a Filter", + onPressed: state.isProcessingSave + ? null + : () => bloc.add(ProcessSelectedFilter()), + child: state.isProcessingSave + ? SizedBox( + height: 20.h, + width: 20.h, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + "Write your message", style: TextStyle( - fontSize: 20, + color: Colors.white, + fontSize: 14.sp, fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 6), - Text( - "Choose your favorite filter and enhance your postcard.", - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w400, - color: const Color(0xff2D3134), - ), - ), - const SizedBox(height: 10), - - // โœ… FIXED: Show ORIGINAL image with ColorFilter preview effect - if (state.imagePath != null) - DottedBorderContainerHolder( - imagePath: state.originalImagePath!, // โœ… Always use ORIGINAL - filter: state.filter ?? "", // โœ… Apply ColorFilter effect only - ), - - const SizedBox(height: 20), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - buildFilterOption( - context, - bloc, - "Original", - File(state.originalImagePath!), - "original", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Black & White", - imageFile, // โœ… Now uses originalImagePath - "bw", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Sepia", - imageFile, // โœ… Now uses originalImagePath - "sepia", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Vintage", - imageFile, // โœ… Now uses originalImagePath - "vintage", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Cool Tone", - imageFile, // โœ… Now uses originalImagePath - "cool", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Contrast", - imageFile, // โœ… Now uses originalImagePath - "contrast", - state.filter, - ), - buildFilterOption( - context, - bloc, - "Soft Glow", - imageFile, // โœ… Now uses originalImagePath - "soft", - state.filter, - ), - ], - ), - - ), - SizedBox( - height: 20.h, - ), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xffF95F62), - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(40), - ), - ), - onPressed: () { - context.read().add(GoToNextStep()); - }, - child: Text( - "Write your message", - style: TextStyle( - color: Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], + ), ), - ), - - // โœ… No loading overlay! - // Filter applies instantly with ColorFilter preview - - ], + ], + ), ), ); }, diff --git a/lib/postcard/views/edit_image_filter.dart b/lib/postcard/views/edit_image_filter.dart index 5b53a3b..285bf95 100644 --- a/lib/postcard/views/edit_image_filter.dart +++ b/lib/postcard/views/edit_image_filter.dart @@ -43,6 +43,16 @@ class _EditImageFilterState extends State { SnackBar(content: Text("Failed to fetch edit details")), ); Navigator.pop(context); + } else if (state is DownloadImageSuccessfully) { + // Only act when processing is complete and an actual filtered image path is available + if (!state.isProcessingSave && state.finalFilteredImagePath != state.originalFilePath) { + widget.pickImagesBloc.add( + SelectedFilter( + imagePath: state.finalFilteredImagePath, + ), + ); + Navigator.pop(context); + } } }, builder: (context, state) { @@ -55,155 +65,156 @@ class _EditImageFilterState extends State { if (state is DownloadImageSuccessfully) { return Scaffold( body: SafeArea( - child: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommonAppBar( - isWhiteLogo: false, - isProfilePage: false, - showDivider: true, - ), - GestureDetector( - onTap: () => Navigator.pop(context), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - const Icon(Icons.arrow_back, size: 20), - const SizedBox(width: 8), - const Text( - "Back", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - const Text( - "Add a Filter", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - "Choose your favorite filter and enhance your postcard.", - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w400, - color: const Color(0xff2D3134), - ), - ), - const SizedBox(height: 10), - - // โœ… FIXED: Show ORIGINAL image with ColorFilter preview effect - DottedBorderContainerHolder( - imagePath: state.filePath, // โœ… Always use ORIGINAL - filter: state.filter, // โœ… Apply ColorFilter effect only - ), - - const SizedBox(height: 20), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - buildFilterOption( - context, - "Original", - File(state.filePath), // โœ… Use original image - "original", - state.filter == "original", - ), - buildFilterOption( - context, - "Black & White", - File(state.filePath), // โœ… Use original image - "bw", - state.filter == "bw", - ), - buildFilterOption( - context, - "Sepia", - File(state.filePath), // โœ… Use original image - "sepia", - state.filter == "sepia", - ), - buildFilterOption( - context, - "Vintage", - File(state.filePath), // โœ… Use original image - "vintage", - state.filter == "vintage", - ), - buildFilterOption( - context, - "Cool Tone", - File(state.filePath), // โœ… Use original image - "cool", - state.filter == "cool", - ), - buildFilterOption( - context, - "Contrast", - File(state.filePath), // โœ… Use original image - "contrast", - state.filter == "contrast", - ), - buildFilterOption( - context, - "Soft Glow", - File(state.filePath), // โœ… Use original image - "soft", - state.filter == "soft", - ), - ], - ), - ), - SizedBox(height: 20.h), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xffF95F62), - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(40), - ), - ), - onPressed: () { - widget.pickImagesBloc.add( - SelectedFilter( - imagePath: state.filteredImagePath, - ), - ); - Navigator.pop(context); - }, - child: Text( - "Save Changes", - style: TextStyle( - color: Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showDivider: true, ), - ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + const Icon(Icons.arrow_back, size: 20), + const SizedBox(width: 8), + const Text( + "Back", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + const Text( + "Add a Filter", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + "Choose your favorite filter and enhance your postcard.", + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: const Color(0xff2D3134), + ), + ), + const SizedBox(height: 10), - // โœ… REMOVED: No loading overlay! - // Filter applies instantly with ColorFilter preview + // Use originalFilePath for Image.file and currentSelectedFilter for ColorFilter + DottedBorderContainerHolder( + imagePath: state.originalFilePath, + filter: state.currentSelectedFilter, + ), - ], + const SizedBox(height: 20), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + buildFilterOption( + context, + "Original", + File(state.originalFilePath), + "original", + state.currentSelectedFilter == "original", + ), + buildFilterOption( + context, + "Black & White", + File(state.originalFilePath), + "bw", + state.currentSelectedFilter == "bw", + ), + buildFilterOption( + context, + "Sepia", + File(state.originalFilePath), + "sepia", + state.currentSelectedFilter == "sepia", + ), + buildFilterOption( + context, + "Vintage", + File(state.originalFilePath), + "vintage", + state.currentSelectedFilter == "vintage", + ), + buildFilterOption( + context, + "Cool Tone", + File(state.originalFilePath), + "cool", + state.currentSelectedFilter == "cool", + ), + buildFilterOption( + context, + "Contrast", + File(state.originalFilePath), + "contrast", + state.currentSelectedFilter == "contrast", + ), + buildFilterOption( + context, + "Soft Glow", + File(state.originalFilePath), + "soft", + state.currentSelectedFilter == "soft", + ), + ], + ), + ), + SizedBox(height: 20.h), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: state.isProcessingSave + ? Colors.grey + : const Color(0xffF95F62), + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + ), + onPressed: state.isProcessingSave + ? null // Disable while background processing is finishing + : () { + // Dispatch the new event to trigger processing and saving + editImageFilterBloc.add(const ProcessAndSaveImage()); + }, + child: state.isProcessingSave + ? SizedBox( + height: 20.h, + width: 20.h, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + "Save Changes", + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), ), ), ); @@ -216,7 +227,7 @@ class _EditImageFilterState extends State { ); } - /// Builds a single filter preview thumbnail - INSTANT with no loading spinner + /// Builds a single filter preview thumbnail - INSTANT Widget buildFilterOption( BuildContext context, String label, @@ -244,7 +255,6 @@ class _EditImageFilterState extends State { ), ), const SizedBox(height: 6), - // โœ… FIXED: Just show label text, NO spinner! Text( label, textAlign: TextAlign.center, @@ -265,153 +275,51 @@ class _EditImageFilterState extends State { ColorFilter getColorFilter(String? filter) { switch (filter) { case "vintage": - // Muted, warm tones without overflow return const ColorFilter.matrix([ - 0.9, - 0.3, - 0.1, - 0, - 0, - 0.2, - 0.8, - 0.1, - 0, - 0, - 0.1, - 0.3, - 0.7, - 0, - 0, - 0, - 0, - 0, - 1, - 0, + 0.9, 0.3, 0.1, 0, 0, + 0.2, 0.8, 0.1, 0, 0, + 0.1, 0.3, 0.7, 0, 0, + 0, 0, 0, 1, 0, ]); case "bw": - // Grayscale return const ColorFilter.matrix([ - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0, - 0, - 0, - 1, - 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, ]); case "sepia": - // Classic soft brown return const ColorFilter.matrix([ - 0.393, - 0.769, - 0.189, - 0, - 0, - 0.349, - 0.686, - 0.168, - 0, - 0, - 0.272, - 0.534, - 0.131, - 0, - 0, - 0, - 0, - 0, - 1, - 0, + 0.393, 0.769, 0.189, 0, 0, + 0.349, 0.686, 0.168, 0, 0, + 0.272, 0.534, 0.131, 0, 0, + 0, 0, 0, 1, 0, ]); case "cool": - // Gentle blue tone โ€” no gamma boost to avoid clipping return const ColorFilter.matrix([ - 1.0, - 0, - 0, - 0, - 0, - 0, - 1.0, - 0, - 0, - 0, - 0, - 0, - 1.1, - 0, - 10, - 0, - 0, - 0, - 1, - 0, + 1.0, 0, 0, 0, 0, + 0, 1.0, 0, 0, 0, + 0, 0, 1.1, 0, 10, + 0, 0, 0, 1, 0, ]); case "contrast": - // Slight contrast increase, safe range return const ColorFilter.matrix([ - 1.1, - 0, - 0, - 0, - -10, - 0, - 1.1, - 0, - 0, - -10, - 0, - 0, - 1.1, - 0, - -10, - 0, - 0, - 0, - 1, - 0, + 1.1, 0, 0, 0, -10, + 0, 1.1, 0, 0, -10, + 0, 0, 1.1, 0, -10, + 0, 0, 0, 1, 0, ]); case "soft": - // Gentle brightness and warmth โ€” fixed to avoid pixelation return const ColorFilter.matrix([ - 1.02, - 0, - 0, - 0, - 5, - 0, - 1.02, - 0, - 0, - 5, - 0, - 0, - 1.02, - 0, - 5, - 0, - 0, - 0, - 1, - 0, + 1.02, 0, 0, 0, 5, + 0, 1.02, 0, 0, 5, + 0, 0, 1.02, 0, 5, + 0, 0, 0, 1, 0, ]); default: diff --git a/lib/postcard/widgets/filter_option_card.dart b/lib/postcard/widgets/filter_option_card.dart index 95cdf78..3ca5669 100644 --- a/lib/postcard/widgets/filter_option_card.dart +++ b/lib/postcard/widgets/filter_option_card.dart @@ -4,13 +4,17 @@ import 'package:flutter/material.dart'; import '../blocs/postcard_creation_bloc.dart'; import '../blocs/postcard_creation_events.dart'; -/// Builds a single filter preview thumbnail - INSTANT with no loading spinner -Widget buildFilterOption(BuildContext context, +/// Mirrors [buildFilterOption] in edit_image_filter.dart. +/// Tapping instantly emits [SelectFilter] โ†’ state updates filter name โ†’ +/// UI rebuilds with ColorFilter widget. Zero processing on tap. +Widget buildFilterOption( + BuildContext context, PostcardCreationBloc postbloc, String label, File imageFile, String filter, - String? selectedFilter,) { + String? selectedFilter, + ) { final isSelected = selectedFilter == filter; return GestureDetector( @@ -33,7 +37,6 @@ Widget buildFilterOption(BuildContext context, ), ), const SizedBox(height: 6), - // โœ… FIXED: Just show label text, NO spinner! Text( label, textAlign: TextAlign.center, @@ -54,59 +57,47 @@ Widget buildFilterOption(BuildContext context, ColorFilter getColorFilter(String? filter) { switch (filter) { case "vintage": - // Muted, warm tones without overflow return const ColorFilter.matrix([ 0.9, 0.3, 0.1, 0, 0, 0.2, 0.8, 0.1, 0, 0, 0.1, 0.3, 0.7, 0, 0, - 0, 0, 0, 1, 0, + 0, 0, 0, 1, 0, ]); - case "bw": - // Grayscale return const ColorFilter.matrix([ 0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, + 0, 0, 0, 1, 0, ]); - case "sepia": - // Classic soft brown return const ColorFilter.matrix([ 0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, - 0, 0, 0, 1, 0, + 0, 0, 0, 1, 0, ]); - case "cool": - // Gentle blue tone โ€” no gamma boost to avoid clipping return const ColorFilter.matrix([ - 1.0, 0, 0, 0, 0, - 0, 1.0, 0, 0, 0, - 0, 0, 1.1, 0, 10, - 0, 0, 0, 1, 0, + 1.0, 0, 0, 0, 0, + 0, 1.0, 0, 0, 0, + 0, 0, 1.1, 0, 10, + 0, 0, 0, 1, 0, ]); - case "contrast": - // Slight contrast increase, safe range return const ColorFilter.matrix([ - 1.1, 0, 0, 0, -10, - 0, 1.1, 0, 0, -10, - 0, 0, 1.1, 0, -10, - 0, 0, 0, 1, 0, + 1.1, 0, 0, 0, -10, + 0, 1.1, 0, 0, -10, + 0, 0, 1.1, 0, -10, + 0, 0, 0, 1, 0, ]); - case "soft": - // Gentle brightness and warmth โ€” fixed to avoid pixelation return const ColorFilter.matrix([ - 1.02, 0, 0, 0, 5, - 0, 1.02, 0, 0, 5, - 0, 0, 1.02, 0, 5, - 0, 0, 0, 1, 0, + 1.02, 0, 0, 0, 5, + 0, 1.02, 0, 0, 5, + 0, 0, 1.02, 0, 5, + 0, 0, 0, 1, 0, ]); - default: return const ColorFilter.mode(Colors.transparent, BlendMode.srcOver); } diff --git a/pubspec.lock b/pubspec.lock index a3b4bbc..7f56029 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" fake_async: dependency: transitive description: @@ -801,18 +801,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1278,10 +1278,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" three_js: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0875b2c..aeba06b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: flutter_otp_text_field: ^1.5.1+1 google_maps_flutter: ^2.13.1 geolocator: ^14.0.2 - equatable: ^2.0.7 + equatable: ^2.0.8 syncfusion_flutter_calendar: ^31.2.4 shared_preferences: ^2.5.3 flutter_launcher_icons: ^0.14.4