apply filte delay solved and refeesh token logic updated

This commit is contained in:
Raj.Ghag
2026-04-01 11:50:00 +05:30
parent b78c83cc4a
commit b37bb3bf2b
13 changed files with 845 additions and 924 deletions

View File

@@ -182,6 +182,22 @@ class LocalPreference {
return null;
}
/// Clear only access token (keep refresh token)
static Future<void> 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<String?> getRefreshToken() async {
final db = await LocalDatabase().database;

View File

@@ -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<void Function()> _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<Response> putApi({
required String url,
dynamic data,
@@ -207,33 +220,76 @@ class NetworkApiService {
}
// ================= REFRESH TOKEN =================
// ✅ Uses _tokenDio (no interceptors) to avoid QueuedInterceptor deadlock
Future<bool> _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<void> _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<String, dynamic>) {
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<String, dynamic> headers) {
_dio.options.headers.addAll(headers);
}
}
}

View File

@@ -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<EditImageFilterEvent, EditImageFilterState> {
// ✅ OPTIMIZATION: Cache decoded image in memory
img.Image? _cachedDecodedImage;
String? _cachedImagePath;
// ✅ OPTIMIZATION: Pre-processed filter cache
final Map<String, String> _filterCache = {};
class EditImageFilterBloc extends Bloc<EditImageFilterEvent, EditImageFilterState> {
EditImageFilterBloc() : super(EditImageFilterInitial()) {
on<DownloadImage>((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<SelectFilter>((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<ProcessAndSaveImage>((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<String, dynamic> 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<void> _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<String> _processImageInIsolate(Map<String, dynamic> 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;
}

View File

@@ -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<Object> get props => [url, type];
}
class SelectFilter extends EditImageFilterEvent {
final String filterName;
const SelectFilter({required this.filterName});
const SelectFilter({
required this.filterName,
});
@override
List<Object> get props => [filterName];
}
class ProcessAndSaveImage extends EditImageFilterEvent {
const ProcessAndSaveImage();
@override
List<Object> get props => [];
}

View File

@@ -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<Object> get props => [filePath, filteredImagePath, processing, filter];
List<Object> 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,
);
}
}

View File

@@ -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<PostcardCreationEvent, PostcardCreationState> {
final ImagePicker _picker = ImagePicker();
// ✅ OPTIMIZATION: Cache decoded image in memory
img.Image? _cachedDecodedImage;
String? _cachedImagePath;
// ✅ OPTIMIZATION: Pre-processed filter cache
final Map<String, String> _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<GoToNextStep>((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<GoToPreviousStep>((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<UploadImage>((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<PickImageFromGallery>((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<PickImageFromCamera>((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<ClearError>((event, emit) {
emit(state.copyWith(errorMessage: null));
});
/* ── SelectFilter ────────────────────────────────────────────────────── */
///
/// EXACT mirror of [EditImageFilterBloc.on<SelectFilter>]:
/// Only updates [filter] in state. The UI immediately shows the
/// ColorFilter widget preview — zero processing, zero delay.
on<SelectFilter>((event, emit) {
if (state.originalImagePath == null) return;
emit(state.copyWith(
filter: event.filterName,
filterProcessingDone: false,
));
});
/* ── ProcessSelectedFilter ───────────────────────────────────────────── */
///
/// EXACT mirror of [EditImageFilterBloc.on<ProcessAndSaveImage>]:
/// 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<ProcessSelectedFilter>((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<String, dynamic> 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<WriteMessage>((event, emit) {
emit(state.copyWith(message: event.message));
});
on<ChangeFontStyle>((event, emit) {
emit(state.copyWith(selectedFont: event.fontName));
});
on<UpdatePostcardNumber>((event, emit) {
emit(state.copyWith(pcNumber: event.pcNumber));
});
on<TogglePurchaseOption>((event, emit) {
emit(state.copyWith(isGift: event.isGift));
});
on<UpdatePurchaseFormData>((event, emit) {
emit(state.copyWith(
pcTitle: event.pcTitle,
@@ -165,75 +251,6 @@ class PostcardCreationBloc
));
});
/* ✅ OPTIMIZED: Select filter - Single click now works! */
on<SelectFilter>((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<WriteMessage>((event, emit) {
emit(state.copyWith(message: event.message));
});
on<ChangeFontStyle>((event, emit) {
emit(state.copyWith(selectedFont: event.fontName));
});
on<UpdatePostcardNumber>((event, emit) {
emit(state.copyWith(pcNumber: event.pcNumber));
});
on<TogglePurchaseOption>((event, emit) {
emit(state.copyWith(isGift: event.isGift));
});
on<StoreUserProfileData>((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<void> _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 '<span style="font-family: Poppins;">${state.message}</span>';
}
return '<span style="font-family: ${state.selectedFont};">${state.message}</span>';
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<String> _processImageInIsolate(Map<String, dynamic> 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;
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<PostcardCreationBloc, PostcardCreationState>(
return BlocConsumer<PostcardCreationBloc, PostcardCreationState>(
// ── 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<PostcardCreationBloc>().add(GoToNextStep());
},
builder: (context, state) {
final bloc = context.read<PostcardCreationBloc>();
// ✅ 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<PostcardCreationBloc>().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<PostcardCreationBloc>()
.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<PostcardCreationBloc>().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
],
],
),
),
);
},

View File

@@ -43,6 +43,16 @@ class _EditImageFilterState extends State<EditImageFilter> {
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<EditImageFilter> {
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<EditImageFilter> {
);
}
/// 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<EditImageFilter> {
),
),
const SizedBox(height: 6),
// ✅ FIXED: Just show label text, NO spinner!
Text(
label,
textAlign: TextAlign.center,
@@ -265,153 +275,51 @@ class _EditImageFilterState extends State<EditImageFilter> {
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:

View File

@@ -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);
}

View File

@@ -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:

View File

@@ -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