A widget created to record videos from the camera. It allows camera selection, zooming and has a timer that will stop the recording after a x seconds.
Dependencies
Add the following packages in the pubspec.yaml first.
- camera: ^version
and run the pub get command from the terminal.
Usage
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: _recordingState == RecordingState.preview
? PreviewWidget(videoFile, onPreviewDone)
: RecordWidget(widget.question, fileCallback, onExit),
),
);
}
fileCallback(file) {
if (file == null) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(Strings.errorVideoFile)));
ExtendedNavigator.of(context).pop();
} else {
setState(() {
// preview the video or send to the backend
});
}
}
onExit(exit) {
if (exit) ExtendedNavigator.of(context).pop();
}
Parameters
@question : Mandatory. A text overlaying the camera preview @fileCallback : Mandatory. Callback called when a file is created from the camera @exitCallback : Mandatory. Callback called when the user exits without a recording
Widget
/// Returns a suitable camera icon for [direction].
IconData getCameraLensIcon(CameraLensDirection direction) {
switch (direction) {
case CameraLensDirection.back:
return Icons.camera_rear;
case CameraLensDirection.front:
return Icons.camera_front;
case CameraLensDirection.external:
return Icons.camera;
}
throw ArgumentError('Unknown lens direction');
}
class RecordWidget extends StatefulWidget {
RecordWidget(this.question, this.fileCallback, this.exitCallback,
{Key key, scaffoldKey})
: super(key: key);
final String question;
final Function(XFile) fileCallback;
final Function(bool) exitCallback;
@override
_RecordWidgetState createState() => _RecordWidgetState();
}
class _RecordWidgetState extends State<RecordWidget>
with WidgetsBindingObserver {
var _recordingState = RecordingState.idle;
CameraController controller;
XFile videoFile;
bool enableAudio = true;
double _minAvailableZoom;
double _maxAvailableZoom;
double _currentScale = 1.0;
double _baseScale = 1.0;
List<CameraDescription> cameras = [];
double countDownDurationSec = 30.0;
double countDownProgress;
// Counting pointers (number of user fingers on screen)
int _pointers = 0;
var _timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_getAvailableCameras();
countDownProgress = countDownDurationSec;
}
void onEnd() {
if (_recordingState == RecordingState.recording) {
_startStopRecording();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// App state changed before we got the chance to initialize.
if (controller == null || !controller.value.isInitialized) {
return;
}
if (state == AppLifecycleState.inactive) {
controller?.dispose();
} else if (state == AppLifecycleState.resumed) {
if (controller != null) {
onNewCameraSelected(controller.description);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Stack(
children: [
_buildCameraPreview(context),
_buildQuestionBox(context),
Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildCameraToolbar(context),
_recordingState == RecordingState.idle
? _buildRecordBt(context)
: _recordingState == RecordingState.recording
? _buildStopRecordBt(context)
: Container(),
_buildSkipBt(context),
],
),
),
),
_buildCloseBt(context),
_buildTimerProgressBar(context),
],
),
),
);
}
_buildRecordBt(BuildContext context) {
return IconButton(
icon: Icon(Icons.fiber_manual_record),
iconSize: 80,
color: Colors.blue,
onPressed: () {
_startStopRecording();
},
);
}
_buildStopRecordBt(BuildContext context) {
return IconButton(
icon: Icon(Icons.stop),
iconSize: 80,
color: Colors.blue,
onPressed: () {
_startStopRecording();
},
);
}
_buildCameraPreview(BuildContext context) {
final size = MediaQuery.of(context).size;
final deviceRatio = size.width / size.height;
if (controller == null || !controller.value.isInitialized) {
return Container(
width: size.width,
height: size.height,
alignment: Alignment.bottomRight,
child: const Text(
'Choose a camera',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.w900,
),
),
);
} else {
return Container(
width: size.width,
height: size.height,
child: Center(
child: Transform.scale(
scale: controller.value.aspectRatio / deviceRatio,
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: Listener(
onPointerDown: (_) => _pointers++,
onPointerUp: (_) => _pointers--,
child: GestureDetector(
onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate,
child: CameraPreview(controller),
),
),
),
),
),
);
}
}
_buildCameraToolbar(BuildContext context) {
if (cameras.isEmpty) {
return Container(
child: const Text('No camera found'),
alignment: Alignment.bottomLeft,
);
} else if (cameras.length == 1) {
// no other cameras found. Don't show a toggle button
return Container();
} else {
return Padding(
padding: const EdgeInsets.only(bottom: Dimens.paddingDouble),
child: GestureDetector(
child: Icon(Icons.flip_camera_android_outlined,
size: 40, color: Colors.white),
onTap: () {
_toggleCameraLens();
},
),
);
}
}
_buildQuestionBox(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 150),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
child: Align(
alignment: Alignment.center,
child: Text(widget.question,
textAlign: TextAlign.center,
style: new TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w900)),
),
decoration: BoxDecoration(
color: Colors.white30, borderRadius: BorderRadius.circular(15)),
padding: new EdgeInsets.all(Dimens.padding),
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.width * 0.2,
),
),
);
}
_buildSkipBt(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: Dimens.paddingDouble),
child: GestureDetector(
child: Text(
Strings.btSkip, style: TextStyle(color: Colors.white, fontSize: 18),),
onTap: () {
widget.fileCallback(null);
},
),
);
}
_buildCloseBt(BuildContext context) {
return Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(right: Dimens.paddingBig, top: 50),
child: IconButton(
icon: Icon(Icons.close),
color: Colors.white,
iconSize: 40,
onPressed: () => widget.exitCallback(true),
)
),
);
}
void _toggleCameraLens() {
// get current lens direction (front / rear)
final lensDirection = controller.description.lensDirection;
CameraDescription newDescription;
if (lensDirection == CameraLensDirection.front) {
newDescription = cameras.firstWhere((description) =>
description.lensDirection == CameraLensDirection.back);
} else {
newDescription = cameras.firstWhere((description) =>
description.lensDirection == CameraLensDirection.front);
}
if (newDescription != null) {
onNewCameraSelected(newDescription);
} else {
print('Asked camera not available');
}
}
_buildTimerProgressBar(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 100),
child: Container(
child: countDownProgress.toInt() >= 10 ? Text(
"00:${countDownProgress.toInt()}") : Text(
"00:0${countDownProgress.toInt()}",
style: new TextStyle(
color: Colors.black, fontWeight: FontWeight.w900)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15)),
padding: new EdgeInsets.only(left: Dimens.paddingBig,
right: Dimens.paddingBig,
top: Dimens.padding,
bottom: Dimens.padding),
),
),
);
}
_getAvailableCameras() {
WidgetsFlutterBinding.ensureInitialized();
availableCameras().then((availableCameras) {
cameras = availableCameras;
print('available cameras found: $cameras');
if (cameras.isNotEmpty) {
try {
onNewCameraSelected(cameras.first);
} catch (e) {
print(e);
}
}
}).catchError((err) {
print('Error: $err.code\nError Message: $err.message');
});
}
void logError(String code, String message) =>
print('Error: $code\nError Message: $message');
void onNewCameraSelected(CameraDescription cameraDescription) async {
if (controller != null) {
await controller.dispose();
}
controller = CameraController(
cameraDescription,
ResolutionPreset.medium,
enableAudio: enableAudio,
);
// If the controller is updated then update the UI.
controller.addListener(() {
if (mounted) setState(() {});
if (controller.value.hasError) {
print('Camera error ${controller.value.errorDescription}');
}
});
try {
await controller.initialize();
_maxAvailableZoom = await controller.getMaxZoomLevel();
_minAvailableZoom = await controller.getMinZoomLevel();
} on CameraException catch (e) {
_showCameraException(e);
}
if (mounted) {
setState(() {});
}
}
void _showCameraException(CameraException e) {
logError(e.code, e.description);
}
void _handleScaleStart(ScaleStartDetails details) {
_baseScale = _currentScale;
}
Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
// When there are not exactly two fingers on screen don't scale
if (_pointers != 2) {
return;
}
_currentScale = (_baseScale * details.scale)
.clamp(_minAvailableZoom, _maxAvailableZoom);
await controller.setZoomLevel(_currentScale);
}
_startStopRecording() {
if (mounted) {
setState(() {
switch (_recordingState) {
// Going from idle to recording
case RecordingState.idle:
_recordingState = RecordingState.recording;
startTimer();
startVideoRecording().then((_) {
if (mounted) setState(() {});
});
break;
// going from recording to preview (video is stopped)
case RecordingState.recording:
stopVideoRecording().then((file) {
if (mounted) setState(() {});
if (file != null) {
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text('Video recorded to ${file.path}')));
videoFile = file;
widget.fileCallback(videoFile);
}
});
_timer?.cancel();
countDownProgress = countDownDurationSec; // reset timer
_recordingState = RecordingState.preview;
break;
case RecordingState.preview:
//no op. Already previewing the video in different widget
break;
}
});
}
}
Future<void> startVideoRecording() async {
if (!controller.value.isInitialized) {
print("camera not initialized");
return;
}
if (controller.value.isRecordingVideo) {
// A recording is already started, do nothing.
return;
}
try {
await controller.startVideoRecording();
} on CameraException catch (e) {
_showCameraException(e);
return;
}
}
Future<XFile> stopVideoRecording() async {
if (!controller.value.isRecordingVideo) {
return null;
}
try {
return controller.stopVideoRecording();
} on CameraException catch (e) {
_showCameraException(e);
return null;
}
}
void startTimer() {
if (mounted) {
_timer = new Timer.periodic(
Duration(seconds: 1),
(Timer timer) => setState(
() {
if (countDownProgress == 0) {
_startStopRecording();
} else {
countDownProgress -= 1;
}
},
),
);
}
}
}