apply filte delay solved and refeesh token logic updated
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
20
pubspec.lock
20
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user