Building a Smart Camera App in Flutter with
AI Filters !!
If you’re looking for the best Flutter app development company for your mobile application then feel free to
contact us at — support@flutterdevs.com
Introduction
What Is a Smart Camera App and Why Does It Matter in Flutter
Choosing the Right Architecture
Setting Up the Project: Dependencies and Configuration
App Entry Point and Service Initialization
Camera Controller — The Brain of the App
Permission Handling Inside the Controller
Camera Initialization and Stream Management
Camera Controls — Flash, Zoom, Focus, and Flip
Capture Flow — Stop Stream, Shoot, Navigate
Building the Camera View
The Preview Stack — Six Layers That Work as One
The Filter Strip UI
The Rule-of-Thirds Grid Overlay
Performance Optimization: Processing Frames Without Jank
Real-World Use Cases
Common Mistakes to Avoid
Conclusion and Next Steps
References
Introduction
A camera app that simply captures a photo is table stakes in 2025. What genuinely impresses users — and what keeps them coming back — is a camera experience that is intelligent: one that applies real-time AI-powered filters to the live viewfinder, processes frames without dropping a single animation frame, and delivers a silky-smooth shutter-to-gallery flow on both Android and iOS.
Flutter, backed by Google’s powerful ecosystem, gives us everything we need to build exactly this kind of app from scratch: the camera package for hardware access and live preview, LiveFilterService for on-device frame processing, GetX for reactive state management, and a rich widget toolkit for crafting animated filter strips, pulsing live indicators, and tap-to-focus overlays.
This guide walks you through building the Smart Camera AI app in Flutter — complete with a live filtered preview, a frame-gated processing pipeline, tap-to-focus, pinch-to-zoom, flash cycling, front/back camera switching, a rule-of-thirds grid overlay, haptic feedback, and a shutter animation. Every step uses the actual production code from the project.
What you will learn:
Initializing services at app launch using Get.putAsync()
Managing CameraController lifecycle — init, stream, dispose — inside a GetxController
Implementing a frame-gated LiveFilterService for real-time filter preview without jank
Building a six-layer Stack preview with raw preview, filtered overlay, grid, controls, zoom badge, and live pill
Wiring SettingsService.showGrid as a reactive alias so the grid toggle in Settings updates the camera view instantly
Cycling flash modes with Samsung-safe initialization
Implementing shutter animation with AnimationController and GetSingleTickerProviderStateMixin
Navigating to a dedicated filter screen after capture with arguments
What Is a Smart Camera App and Why Does It Matter in Flutter?
A smart camera app is more than a thin wrapper around the device camera API. It is a real-time image processing pipeline that intercepts each frame coming off the camera sensor, applies one or more transformations, and renders the result back to the viewfinder — all before the user taps the shutter.
In a Flutter context, this means:
CameraController streams CameraImage objects to Dart at up to 30 fps using startImageStream().
A processing gate inside LiveFilterService ensures only one frame is in-flight at any time.
The processed Uint8List is pushed into a reactive Rxn<Uint8List> previewBytes observable.
The view layer renders it via Image.memory(bytes, gaplessPlayback: true), overlaid on the raw CameraPreview.
On capture, the stream is stopped first (required on Android), a full-resolution photo is taken, and the app navigates to a separate filter editing screen.
Key Benefits:
Instant Gratification:
Users see filtered results live in the viewfinder — no post-processing wait after capture
On-Device Privacy:
No frames leave the device; all processing runs locally
Offline Capability:
Works fully without internet — no cloud ML dependency
Settings-Reactive UI:
Grid, haptics, and quality preferences update the camera view instantly via a reactive SettingsService
Samsung-Safe Design:
Explicit flash initialization and stream-stop-before-capture prevent common Android OEM crashes
Choosing the Right Architecture
The app follows GetX’s clean service/module separation. Each concern lives in exactly one place.
LayerClassResponsibilityApp
Services
SettingsService : Persistent reactive settings (grid, haptics, quality)
App Services
GalleryService : Gallery read/write operationsCamera
Logic
CameraViewController : All camera state, stream, controls, captureFilter Processing
LiveFilterService : Frame-gated YUV→RGB + filter pipelineFilter Metadata
AiFilterModel : Filter catalog: name, icon, gradient colors, typeCamera
UI
CameraView
Pure UI consumer — zero business
logicRouting
AppPages / AppRoutes
Named route definitions
Design Principle:
CameraViewController uses Get.find<SettingsService>() to read settings reactively. It exposes showGrid as a direct alias to _settings.showGrid so the camera view reacts to Settings changes without any message passing or controller coupling.
Setting Up the Project: Dependencies and Configuration
Step 1: Add Dependencies
Add the following to your pubspec.yaml:
yaml
dependencies:
flutter:
sdk: flutter
# State Management
get: ^4.6.6
# Camera
camera: ^0.10.5+9
# Image processing
image: ^4.1.7
# Permissions
permission_handler: ^11.3.1
# Storage
image_gallery_saver: ^2.0.3
path_provider: ^2.1.3
path: ^1.9.0
# Utilities
intl: ^0.19.0
Run flutter pub get to install.
Step 2: Android Configuration
Set minSdkVersion to 21 in android/app/build.gradle:
kotlin
android {
defaultConfig {
minSdk = 21
targetSdk = 34
}
}
Add permissions to android/app/src/main/AndroidManifest.xml:
xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
Step 3: iOS Configuration
Add the following keys to ios/Runner/Info.plist:
xml
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to capture and apply AI-powered filters to your photos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is used when recording video.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app saves filtered photos to your photo library.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app adds filtered photos to your photo library.</string>
Set minimum iOS version in ios/Podfile:
ruby
platform :ios, '13.0'
App Entry Point and Service Initialization
main.dart is deliberately minimal. Only the two services that must outlive all screens — SettingsService and GalleryService — are registered as async singletons before runApp(). Everything else is lazy-put via bindings.
dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Get.putAsync(() => SettingsService().init());
await Get.putAsync(() => GalleryService().init());
runApp(const SmartCameraApp()); }
SmartCameraApp applies a full dark theme seeded from 0xFF1A1A2E (a near-black navy), forces ThemeMode.dark, and wires GetX routing:
dart
class SmartCameraApp extends StatelessWidget {
const SmartCameraApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Smart Camera AI',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1A2E),
brightness: Brightness.dark, ),
useMaterial3: true,
fontFamily: 'SF Pro Display', ),
themeMode: ThemeMode.dark,
initialBinding: InitialBinding(),
initialRoute: AppRoutes.HOME,
getPages: AppPages.routes, );} }
Why Get.putAsync() instead of Get.put()?
Both SettingsService and GalleryService perform async initialization — reading SharedPreferences and scanning the device gallery respectively. Get.putAsync() awaits the init() future before runApp() fires, guaranteeing both services are fully ready before the first frame renders. This prevents null-access errors on cold start.
Camera Controller — The Brain of the App
CameraViewController extends GetxController with
GetSingleTickerProviderStateMixin to own the shutter animation. It holds all camera state, wires SettingsService reactively, manages the filter stream, and handles the full capture flow.
dart
class CameraViewController extends GetxController
with GetSingleTickerProviderStateMixin {
final _settings = Get.find<SettingsService>();
// Camera state
CameraController? cameraCtrl;
final cameras = <CameraDescription>[].obs;
final isInitialized = false.obs;
final isCapturing = false.obs;
final isFrontCamera = false.obs;
final flashMode = FlashMode.off.obs;
final zoomLevel = 1.0.obs;
final minZoom = 1.0.obs;
final maxZoom = 1.0.obs;
final hasPermission = false.obs;
// showGrid is a direct alias to SettingsService — reactive across the whole app
RxBool get showGrid => _settings.showGrid;
// Live filter state
final previewBytes = Rxn<Uint8List>();
final selectedFilter = AiFilterModel.allFilters.first.obs;
final _liveService = LiveFilterService();
bool _streamActive = false;
int _sensorDegrees = 90;
// Shutter animation
late AnimationController shutterAnimCtrl;
late Animation<double> shutterAnim;
Lifecycle: onInit, onReady, onClose
dart
@override
void onInit() {
super.onInit();
shutterAnimCtrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 150));
shutterAnim = Tween<double>(begin: 1.0, end: 0.85).animate(
CurvedAnimation(parent: shutterAnimCtrl, curve: Curves.easeInOut));
}
@override
void onReady() {
super.onReady();
// Read preset filter from navigation arguments (e.g. launched from home)
final args = Get.arguments as Map<String, dynamic>?;
final presetName = (args?['presetFilterName'] as String?) ?? FilterType.none.name;
final match = AiFilterModel.allFilters.firstWhere(
(f) => f.type.name == presetName,
orElse: () => AiFilterModel.allFilters.first,
);
selectedFilter.value = match;
requestPermissionsAndInit();
}
@override
void onClose() {
_stopStream();
cameraCtrl?.dispose();
shutterAnimCtrl.dispose();
super.onClose();
}
Why onReady() instead of onInit() for camera initialization?
onReady() fires after the first frame renders, guaranteeing that Get.arguments is populated and that the context is ready for snackbars. Using onInit() for async operations that trigger UI feedback can silently fail on the first route push.
Permission Handling Inside the Controller
Permissions are requested inside the controller, not the view, keeping CameraView a pure UI layer. If permission is denied, a styled red snackbar appears. The view reacts to hasPermission via Obx and renders a dedicated _PermissionDenied widget with a retry button:
dart
Future<void> requestPermissionsAndInit() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
hasPermission.value = false;
Get.snackbar(
'Permission Required',
'Camera permission is needed.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade800,
colorText: Colors.white, );
return; }
hasPermission.value = true;
await _initCamera(); }
The view handles all three states — no permission, initializing, and ready — in a single root Obx:
dart
Obx(() {
if (!controller.hasPermission.value) {
return _PermissionDenied(onRetry: controller.requestPermissionsAndInit); }
if (!controller.isInitialized.value) {
return const Center(
child: CircularProgressIndicator(color: Colors.white)); }
return _CameraBody(controller: controller); }),
_PermissionDenied renders a full-screen centered column with an icon, a message,
And a Grant Permission ElevatedButton that,
calls requestPermissionsAndInit() again:
dart
class _PermissionDenied extends StatelessWidget {
final VoidCallback onRetry;
const _PermissionDenied({required this.onRetry});
@override
Widget build(BuildContext context) => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.no_photography, color: Colors.white54, size: 64),
const SizedBox(height: 16),
const Text('Camera permission required',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Grant Permission'),
),
]),
);
}
Camera Initialization and Stream Management
_startCamera() always uses ResolutionPreset.low for the image stream. This is a deliberate performance decision — low-resolution frames process significantly faster, keeping the filter preview smooth at 30 fps on mid-range devices. takePicture() captures at the sensor's native full resolution regardless of this preset.
dart
Future<void> _startCamera(CameraDescription desc) async {
_stopStream();
await cameraCtrl?.dispose();
isInitialized.value = false;
previewBytes.value = null;
_sensorDegrees = desc.sensorOrientation;
cameraCtrl = CameraController(
desc,
ResolutionPreset.low, // stream: low for speed
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await cameraCtrl!.initialize();
// Samsung fix: explicitly reset flash to OFF after init.
// Samsung devices default to FlashMode.auto, which fires the flash
// on every capture without this reset.
try {
await cameraCtrl!.setFlashMode(FlashMode.off);
} catch (_) {}
flashMode.value = FlashMode.off;
minZoom.value = await cameraCtrl!.getMinZoomLevel();
maxZoom.value = await cameraCtrl!.getMaxZoomLevel();
zoomLevel.value = minZoom.value;
isInitialized.value = true;
update();
if (selectedFilter.value.type != FilterType.none) _startStream();
}
The stream is only started when a filter other than none (Original) is active. When the user switches back to Original, _stopStream() nulls previewBytes, which makes the filtered overlay disappear and exposes the native CameraPreview underneath.
dart
void _startStream() {
if (cameraCtrl == null || !cameraCtrl!.value.isInitialized) return;
if (_streamActive) return;
_streamActive = true;
cameraCtrl!.startImageStream((CameraImage frame) async {
final bytes = await _liveService.processFrame(
frame, selectedFilter.value.type, _sensorDegrees);
if (bytes != null && _streamActive) previewBytes.value = bytes;
});
}
void _stopStream() {
if (!_streamActive) return;
_streamActive = false;
try { cameraCtrl?.stopImageStream(); } catch (_) {}
previewBytes.value = null;
}
Why _streamActive is a plain bool, not RxBool:
It is a pure internal gate — the view never needs to observe it. Using a plain bool avoids the overhead of notifying zero listeners on every single frame callback.
Camera Controls — Flash, Zoom, Focus, and Flip
Flash Cycling
Flash cycles through four modes: off → auto → always → torch. The flashIcon computed getter drives the top-bar icon reactively — no switch statement needed in the view:
dart
Future<void> cycleFlash() async {
if (cameraCtrl == null || !cameraCtrl!.value.isInitialized) return;
final modes = [FlashMode.off, FlashMode.auto, FlashMode.always, FlashMode.torch];
final next = modes[(modes.indexOf(flashMode.value) + 1) % modes.length];
await cameraCtrl!.setFlashMode(next);
flashMode.value = next;
}
IconData get flashIcon {
switch (flashMode.value) {
case FlashMode.auto: return Icons.flash_auto;
case FlashMode.always: return Icons.flash_on;
case FlashMode.torch: return Icons.flashlight_on;
default: return Icons.flash_off;
}
}
Pinch-to-Zoom
Zoom is clamped between minZoom and maxZoom before being sent to the platform controller, preventing out-of-range exceptions:
dart
Future<void> setZoom(double value) async {
if (cameraCtrl == null) return;
zoomLevel.value = value.clamp(minZoom.value, maxZoom.value);
await cameraCtrl!.setZoomLevel(zoomLevel.value);
}
The _buildRawPreview widget listens to onScaleUpdate and feeds the cumulative scale into setZoom:
dart
onScaleUpdate: (d) =>
controller.setZoom(controller.zoomLevel.value * d.scale),
Tap-to-Focus
Focus and exposure points are normalized to 0.0–1.0 relative coordinates. The tap is captured in _buildRawPreview via onTapUp:
dart
Future<void> setFocusPoint(Offset offset, Size previewSize) async {
if (cameraCtrl == null || !cameraCtrl!.value.isInitialized) return;
try {
await cameraCtrl!.setFocusPoint(Offset(
(offset.dx / previewSize.width).clamp(0.0, 1.0),
(offset.dy / previewSize.height).clamp(0.0, 1.0),
));
} catch (_) {}
}
Grid Toggle
toggleGrid() writes directly to SettingsService, which persists the preference and propagates the reactive change to every Obx subscriber simultaneously — including in the Settings screen if it is open in the background:
dart
void toggleGrid() =>
_settings.setShowGrid(!_settings.showGrid.value);
Filter Selection with Haptics
When a filter is selected, the controller checks SettingsService.enableHaptics before firing HapticFeedback.selectionClick(). The stream is started or stopped based on whether the new filter requires frame processing:
dart
void selectFilter(AiFilterModel filter) {
selectedFilter.value = filter;
if (_settings.enableHaptics.value) {
HapticFeedback.selectionClick();
}
if (filter.type == FilterType.none) {
_stopStream();
} else if (!_streamActive) {
_startStream();
}
}
Capture Flow — Stop Stream, Shoot, Navigate
The capture sequence has three critical steps that must happen in this exact order:
dart
Future<void> captureImage() async {
if (cameraCtrl == null || isCapturing.value) return;
if (!cameraCtrl!.value.isInitialized) return;
try {
isCapturing.value = true;
if (_settings.enableHaptics.value) HapticFeedback.mediumImpact();
// Shutter animation: scale 1.0 → 0.85 → 1.0 over 150ms
shutterAnimCtrl.forward().then((_) => shutterAnimCtrl.reverse());
// Step 1: Stop the image stream BEFORE takePicture().
// On Android, the stream and takePicture() cannot run simultaneously.
// Failing to stop the stream first causes "getSurface() on a null
// object reference" on Samsung and other OEM devices.
_stopStream();
// Step 2: Capture at full sensor resolution.
// takePicture() always uses native full resolution on Android,
// regardless of the ResolutionPreset set on the controller
// (which only controls the preview/stream resolution).
final xFile = await cameraCtrl!.takePicture();
// Step 3: Navigate to the filter editing screen with the image path
// and the currently active filter as preset.
Get.toNamed(AppRoutes.FILTER, arguments: {
'imagePath': xFile.path,
'presetFilterName': selectedFilter.value.type.name,
});
} catch (e) {
Get.snackbar('Capture Failed', e.toString(),
snackPosition: SnackPosition.BOTTOM);
// Restart the stream so the live preview recovers if capture failed.
if (selectedFilter.value.type != FilterType.none) _startStream();
} finally {
isCapturing.value = false;
}
}
Why not use a separate high-resolution CameraController for capture? Creating a second CameraController and immediately disposing it corrupts the ImageReader surface reference on Samsung devices, causing a getSurface() on a null object reference crash. The single-controller approach with stream-stop-before-capture is the correct and reliable pattern.
Building the Camera View
CameraView is a GetView<CameraViewController> — a zero-boilerplate base class that wires the controller getter automatically. _CameraBody splits the screen into an Expanded preview area and a fixed _BottomPanel, ensuring the filter strip and shutter button never overlap the viewfinder on any screen size.
dart
class _CameraBody extends StatelessWidget {
final CameraViewController controller;
const _CameraBody({required this.controller});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: _PreviewStack(controller: controller)),
_BottomPanel(controller: controller),
],
);
}
}
The Preview Stack — Six Layers That Work as One
_PreviewStack is the heart of the camera UI. It renders six layers inside a Stack(fit: StackFit.expand), each responsible for exactly one concern:
Layer 1 — Raw CameraPreview : (always present — handles AF/AE/zoom)
Layer 2 — Filtered frame overlay : (covers Layer 1 when previewBytes != null)
Layer 3 — Rule-of-thirds grid : (conditional on showGrid)
Layer 4 — Top bar : (flash icon, title, back button)
Layer 5 — Zoom level badge : (top-right, hidden when maxZoom ≤ 1.01)
Layer 6 — Live filter pill : (bottom-center, hidden when Original selected)
Why always render the raw CameraPreview even when a filter is active?
The native CameraPreview layer handles autofocus, auto-exposure, and pinch-to-zoom at the platform level. Hiding it or building it conditionally would break these features. The filtered overlay simply sits on top and covers it completely — the raw preview keeps doing its job invisibly underneath.
Layer 1: Raw Preview with Correct Aspect Ratio
On Android, previewSize is returned in landscape orientation (width > height). Swapping width and height inside SizedBox before wrapping with FittedBox.cover ensures the preview fills the portrait screen correctly without pillarboxing:
dart
Widget _buildRawPreview(BuildContext context) {
final ctrl = controller.cameraCtrl!;
final prev = ctrl.value.previewSize;
return GestureDetector(
onTapUp: (d) => controller.setFocusPoint(
d.localPosition, MediaQuery.of(context).size),
onScaleUpdate: (d) =>
controller.setZoom(controller.zoomLevel.value * d.scale),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
// Swap width/height: previewSize is landscape on Android
width: prev != null ? prev.height : 1,
height: prev != null ? prev.width : 1,
child: CameraPreview(ctrl),
),
),
),
);
}
Layer 2: Filtered Overlay
SizedBox.expand is critical here. Without it, Image.memory sizes itself to the image's intrinsic dimensions inside the Stack, leaving visible gaps at the edges. gaplessPlayback: true prevents Flutter from flashing a blank white frame between each buffer update — essential for a smooth 30 fps filtered preview.
dart
Obx(() {
final bytes = controller.previewBytes.value;
if (bytes == null) return const SizedBox.shrink();
return SizedBox.expand(
child: Image.memory(
bytes,
fit: BoxFit.cover,
gaplessPlayback: true, // no white flash between frames ), ); }),
Layer 6: Live Filter Pill with Pulsing Dot
The live filter pill appears at the bottom of the viewfinder when any filter other than Original is active. It shows the filter name alongside a pulsing dot that animates between 40% and 100% opacity at 900ms intervals, signaling to the user that the preview is live:
dart
class _LiveDotState extends State<_LiveDot>
with SingleTickerProviderStateMixin {
late AnimationController _anim;
@override
void initState() {
super.initState();
_anim = AnimationController(
vsync: this, duration: const Duration(milliseconds: 900))
..repeat(reverse: true); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
width: 8, height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color.withOpacity(0.4 + 0.6 * _anim.value), ), ), );
}
}
The Filter Strip UI
_FilterStrip is a horizontal ListView of animated filter tiles rendered in the _BottomPanel. Each tile shows the filter's icon and name, with its unique gradient colors sourced from AiFilterModel. An AnimatedContainer with a 180ms duration handles the selection state transition — no manual setState or AnimationController needed per tile.
dart
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 62,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? filter.gradientColors.last : Colors.white12,
width: isSelected ? 2 : 1, ),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isSelected
? filter.gradientColors
: [
filter.gradientColors.first.withOpacity(0.35),
filter.gradientColors.last.withOpacity(0.35),
], ), ),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(filter.icon,
color: Colors.white.withOpacity(isSelected ? 1.0 : 0.55),
size: 22),
const SizedBox(height: 4),
Text(filter.name,
style: TextStyle(
color: isSelected ? Colors.white : Colors.white54,
fontSize: 9,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center), ], ), ),
A filter name label sits above the strip, showing the active filter’s name in its gradient color, or “Original” in muted white when no filter is active. A small gradient dot beside the name matches the filter’s color:
dart
Obx(() {
final f = controller.selectedFilter.value;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (f.type != FilterType.none)
Container(
width: 8, height: 8,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: f.gradientColors),
),
),
Text(
f.type == FilterType.none ? 'Original' : f.name,
style: TextStyle(
color: f.type == FilterType.none
? Colors.white38
: f.gradientColors.last,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
],
);
}),
The Rule-of-Thirds Grid Overlay
The grid is drawn by _GridPainter, a CustomPainter that draws two vertical and two horizontal lines at one-third and two-thirds of the canvas dimensions. It never repaints — shouldRepaint returns false because the grid lines are static. Only the Obx wrapper rebuilds when showGrid changes, mounting or unmounting the painter entirely:
dart
class _GridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final p = Paint()
..color = Colors.white.withOpacity(0.25)
..strokeWidth = 0.6;
for (int i = 1; i < 3; i++) {
canvas.drawLine(
Offset(size.width * i / 3, 0),
Offset(size.width * i / 3, size.height),
p,
);
canvas.drawLine(
Offset(0, size.height * i / 3),
Offset(size.width, size.height * i / 3),
p,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter _) => false;
}
Because toggleGrid() writes to SettingsService which persists the value, the grid preference survives app restarts. A photographer who always shoots with the grid enabled sets it once in Settings and never thinks about it again.
Performance Optimization: Processing Frames Without Jank
1. ResolutionPreset.low for the Stream
The stream controller is initialized with ResolutionPreset.low (typically 352×288 on Android). Processing a 352×288 frame is approximately 10× faster than processing a 1080p frame, keeping the filter pipeline smooth at 30 fps on mid-range devices. takePicture() captures at the sensor's native full resolution regardless.
2. The _streamActive Gate
The boolean gate inside _startStream ensures exactly one frame is processed at a time. If LiveFilterService.processFrame() is still running when the next CameraImage arrives, that frame is silently dropped. This prevents queue buildup that would cause increasing memory pressure and latency:
dart
cameraCtrl!.startImageStream((CameraImage frame) async {
final bytes = await _liveService.processFrame(
frame, selectedFilter.value.type, _sensorDegrees);
if (bytes != null && _streamActive) previewBytes.value = bytes;
});
3. Stop Stream Before Capture
The stream is explicitly stopped before takePicture(). On Android, running the image stream and takePicture() simultaneously attempts to write to two surfaces at once, causing crashes on Samsung, Xiaomi, and other OEM devices. The stream is restarted in the catch block to recover the live preview if capture fails for any reason.
4. gaplessPlayback: true
Without this flag, Flutter disposes the previous image decoder and creates a new one for every frame, producing a brief white flash at 30 fps. gaplessPlayback: true keeps the previous frame visible while the new one decodes, eliminating the strobe entirely.
5. shouldRepaint Returns false on the Grid Painter
The _GridPainter draws static lines that never change. Returning false from shouldRepaint tells Flutter's render tree never to call paint() again after the first draw, saving one canvas operation per frame while the grid is visible.
Real-World Use Cases
Smart Camera Apps with AI Filters find practical application across many product domains.
1. Social and Content Creation Apps Creators applying brand-consistent filters before posting eliminate the need for third-party editing apps. The live preview means what they see is exactly what gets captured — no surprises after the shutter.
2. E-Commerce Product Photography Sellers photographing products with warm or vivid filters generate shelf-ready images directly from their phone, bypassing any desktop editing step entirely.
3. Healthcare and Telemedicine Skin condition monitoring apps can apply edge-detect or enhanced-contrast filters to surface details in photos submitted by patients during remote consultations — all processed on-device with zero cloud upload.
4. Real Estate and Property Agents photographing properties with consistent warm filters and an organized gallery module can share curated photo albums directly from the app to prospective buyers.
5. EdTech and Document Scanning Grayscale and sketch filters, combined with a crop step in the filter editing screen, convert whiteboard photos and handwritten notes into clean, readable study documents instantly.
6. Events and Hospitality Event photo booths built on Flutter can offer guests a selection of branded AI filters, capturing and sharing in one tap — fully offline with no cloud dependency.
7. Security and Inspection Field inspection engineers applying edge-detect filters surface cracks and structural anomalies in photographs, creating annotated records on-device for compliance and insurance documentation.
8. Fashion and Retail In-store stylists can photograph outfits with vivid or cool filters matching brand aesthetics, then share directly from the gallery screen to a client-facing channel.
Common Mistakes to Avoid
1. Not Stopping the Stream Before takePicture()
Running the image stream and takePicture() simultaneously is the single most common crash in Flutter camera apps. On many Android OEM devices it causes getSurface() on a null object reference.
dart
// WRONG: stream still running when takePicture() fires
final xFile = await cameraCtrl!.takePicture();
// CORRECT: stop stream first, then capture
_stopStream();
final xFile = await cameraCtrl!.takePicture();
2. Not Resetting Flash Mode After initialize()
Samsung and some other Android OEM devices default to FlashMode.auto after CameraController.initialize(). Without an explicit reset, the flash fires on every capture.
dart
// WRONG: flash fires unexpectedly on Samsung devices
await cameraCtrl!.initialize();
// CORRECT: explicitly set flash OFF after init
await cameraCtrl!.initialize();
try { await cameraCtrl!.setFlashMode(FlashMode.off); } catch (_) {}
flashMode.value = FlashMode.off;
3. Not Using gaplessPlayback: true for the Filtered Preview
Without gaplessPlayback: true, Image.memory creates a new decoder for every incoming frame, producing a white strobe effect at 30 fps that makes the live filter preview unusable.
dart
// WRONG: white flash between every frame
Image.memory(bytes, fit: BoxFit.cover)
// CORRECT: keep previous frame while next frame decodes
Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true)
4. Not Wrapping Image.memory in SizedBox.expand
Without SizedBox.expand, Image.memory inside a Stack sizes itself to its intrinsic image dimensions instead of filling the available space, leaving visible gaps around the edges of the viewfinder.
dart
// WRONG: image leaves gaps at edges
Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true)
// CORRECT: force image to fill the Stack cell before BoxFit.cover scales it
SizedBox.expand(
child: Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true),
)
5. Using AnimatedSwitcher + ValueKey for the Filtered Preview
AnimatedSwitcher with a ValueKey on a widget that updates 30 times per second causes a full layout reset on every frame, producing visible jitter. Always render both the raw preview and the filtered overlay simultaneously — let the overlay cover the raw preview rather than switching between them.
dart
// WRONG: layout reset every frame at 30fps
AnimatedSwitcher(
duration: Duration(milliseconds: 100),
child: bytes != null
? Image.memory(bytes, key: ValueKey(bytes.hashCode))
: CameraPreview(ctrl),
)
// CORRECT: both layers always present; overlay covers raw when active
Stack(children: [
_buildRawPreview(context),
Obx(() {
final bytes = controller.previewBytes.value;
if (bytes == null) return const SizedBox.shrink();
return SizedBox.expand(
child: Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true));
}),
])
6. Not Disposing CameraController in onClose()
A CameraController that is not disposed continues consuming the camera hardware after navigation, prevents other apps from accessing the camera, and leaks platform channels silently.
dart
@override
void onClose() {
_stopStream(); // stop stream before dispose
cameraCtrl?.dispose(); // release camera hardware
shutterAnimCtrl.dispose(); // release animation ticker
super.onClose();
}
7. Creating a Second CameraController for High-Resolution Capture
A common pattern seen in tutorials is creating a second CameraController with ResolutionPreset.high solely for capture. On Samsung devices, creating and disposing a second controller corrupts the ImageReader surface reference, causing getSurface() on a null object reference. Use a single controller — takePicture() always captures at full sensor resolution regardless of the stream preset.
dart
// WRONG: second controller corrupts surface reference on Samsung
final hiRes = CameraController(desc, ResolutionPreset.high);
await hiRes.initialize();
final file = await hiRes.takePicture();
await hiRes.dispose();
// CORRECT: single controller, takePicture() is always full resolution
_stopStream();
final file = await cameraCtrl!.takePicture();
Download the Complete Source Code
The complete source code for this Flutter Smart Camera AI app is available on GitHub.
Repository Structure:
smart_camera_app/
├── lib/
│ ├── main.dart
│ └── app/
│ ├── bindings/
│ │ └── initial_binding.dart
│ ├── data/
│ │ ├── models/
│ │ │ └── ai_filter_model.dart
│ │ └── services/
│ │ ├── face_detection_service.dart
│ │ ├── filter_service.dart
│ │ ├── gallery_service.dart
│ │ ├── live_filter_service.dart
│ │ └── settings_service.dart
│ ├── modules/
│ │ ├── camera/
│ │ │ ├── camera_binding.dart
│ │ │ ├── camera_controller.dart
│ │ │ └── camera_view.dart
│ │ ├── filter/
│ │ │ ├── filter_binding.dart
│ │ │ ├── filter_controller.dart
│ │ │ └── filter_view.dart
│ │ ├── gallery/
│ │ │ ├── gallery_binding.dart
│ │ │ └── gallery_view.dart
│ │ ├── home/
│ │ │ ├── home_binding.dart
│ │ │ ├── home_controller.dart
│ │ │ └── home_view.dart
│ │ └── settings/
│ │ ├── settings_binding.dart
│ │ └── settings_view.dart
│ ├── routes/
│ │ ├── app_pages.dart
│ │ └── app_routes.dart
│ └── widgets/
│ ├── face_overlay_painter.dart
│ └── filter_chip_bar.dart
├── android/
├── ios/
├── pubspec.yaml
└── README.md
Quick Start:
bash
# Clone the repository
git clone https://github.com/onlykrishna/Smart_Camera_APP.git
# Navigate to project
cd Smart_Camera_APP
# Install dependencies
flutter pub get
# iOS only (macOS)
cd ios && pod install && cd ..
# Run on a physical device — emulators do not expose real camera hardware
flutter run
Important:
Always test on a physical device. Android and iOS emulators simulate the camera using a still-image loop and do not expose the YUV420 stream required for real-time filter processing. Performance characteristics on emulators also bear no resemblance to real device behavior.
Conclusion and Next Steps
Building a smart camera app in Flutter comes down to three things done well: a properly managed CameraController that never fights the platform, a frame-gated processing pipeline that keeps the UI thread free, and a reactive UI architecture that reads from a single source of truth in SettingsService — so settings changes propagate instantly to every screen without any message passing.
The architecture described in this guide is intentionally scalable. Adding a new filter requires one new FilterType enum value, one AiFilterModel entry, and one case in LiveFilterService.processFrame(). The filter strip, live pill, and filter editing screen all update automatically. No other changes needed.
Key Takeaways:
Always stop the image stream before takePicture() — this is the most common Android camera crash
Reset flash to FlashMode.off explicitly after initialize() — Samsung devices default to auto
Use ResolutionPreset.low for the stream; let takePicture() capture at full sensor resolution
Wrap Image.memory in SizedBox.expand to fill the preview area without gaps
Set gaplessPlayback: true on Image.memory to eliminate the 30 fps white strobe
Never use AnimatedSwitcher + ValueKey on a widget that updates 30 times per second
Dispose CameraController, AnimationController, and the stream in onClose() — every single one
Expose settings as reactive RxBool aliases in the controller so the view never needs to know where data lives
Extend This Foundation With:
Face detection overlays
use FaceDetectionService + FaceOverlayPainter already in the project to draw landmarks on detected faces in the live preview
Video recording with filters
extend LiveFilterService to encode processed frames into an MP4 using ffmpeg_kit_flutter
AI scene detection
use google_mlkit_image_labeling to detect the scene type (food, landscape, portrait) and auto-suggest the most flattering filter
Cloud backup
sync the gallery to Firebase Storage with delta sync so users never lose a shot
Biometric lock
gate gallery access behind fingerprint or face authentication with local_auth
AR stickers
extend FaceOverlayPainter to draw positioned stickers anchored to detected face landmarks
References
camera — Flutter camera plugin: camera | Flutter package
A Flutter plugin for controlling the camera. Supports previewing the camera feed, capturing images and video, and…pub.dev
image — Pure Dart image processing library: image | Dart package
Dart Image Library provides server and web apps the ability to load, manipulate, and save images with various image…pub.dev
get — GetX state management and navigation: get | Flutter package
Open screens/snackbars/dialogs without context, manage states and inject dependencies easily with GetX.pub.dev
permission_handler — Cross-platform permission management:
permission_handler | Flutter package
Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check…pub.dev
image_gallery_saver — Save images to device gallery:
image_gallery_saver package – All Versions
Pub is the package manager for the Dart programming language, containing reusable libraries & packages for Flutter and…pub.dev
google_mlkit_image_labeling — On-device ML Kit inference: \
google_mlkit_image_labeling | Flutter package
A Flutter plugin to use Google's ML Kit Image Labeling to detect and extract information about entities in an image…pub.dev
path_provider — Filesystem path resolution:
path_provider | Flutter package
Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data…pub.dev
Connect With Us
Feel free to connect with us:And read more articles from FlutterDevs.com.
FlutterDevs team of Flutter developers to build high-quality and functionally-rich apps. Hire a Flutter developer for your cross-platform Flutter mobile app project hourly or full-time as per your requirement! For any flutter-related queries, you can connect with us on Facebook, GitHub, Twitter, and LinkedIn.
We welcome feedback and hope that you share what you’re working on using #FlutterDevs. We truly enjoy seeing how you use Flutter to build beautiful, interactive web experiences.
Need help building production-grade Flutter apps? FlutterDevs helps teams ship faster with solid architecture, better UX, and practical AI features. Reach us at support@flutterdevs.com.


