I have an project coded in Flutter where the main system runs on a remote computer called totem. The system have some gifs running as the background decoration of the app. To update the appearance of the totem I use an admin dashboard to send the updates using SNS and each totem reads the message in the SQS queue. As the system should not rely on internet I download the pictures in data/flutter_assets/lib/assets/videos folder because the executable file just can read that filepath (There's no lib/assets after building). The download works without problems but the issue arises when Flutter needs to update the Image.assets(), because it reads the same file all over again instead of the updated one. The gifs are stored with the same name, it just needs to reload the assets but as I am a beginner with Flutter, I dont really know how to approach to this problem. This a sample screen:
// lib/screens/welcome_screen.dart
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../mixins/activity_timer_mixin.dart';
import './instructions_screen.dart';
class WelcomeScreen extends StatefulWidget {
final CameraDescription camera;
const WelcomeScreen({super.key, required this.camera});
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen> with ActivityTimerMixin {
Timer? _timer;
bool _isProcessing = false;
@override
void initState() {
super.initState();
_initializeSharedController();
}
Future<void> _initializeSharedController() async {
if (sharedController != null) return;
sharedController = CameraController(
widget.camera,
AppConfig.defaultCameraResolution,
enableAudio: AppConfig.enableAudio,
imageFormatGroup: ImageFormatGroup.jpeg,
);
try {
await sharedController!.initialize();
if (mounted) {
setState(() {});
_startGestureDetection();
}
} catch (e) {
print('Error initializing camera: $e');
}
}
void _startGestureDetection() {
_timer?.cancel();
_timer = Timer.periodic(
AppConfig.gestureDetectionInterval,
(timer) {
if (!_isProcessing) {
_detectGesture();
}
},
);
}
Future<void> _detectGesture() async {
if (sharedController == null || !sharedController!.value.isInitialized) return;
try {
_isProcessing = true;
final image = await sharedController!.takePicture();
final bytes = await image.readAsBytes();
var request = http.MultipartRequest(
'POST',
Uri.parse(AppConfig.detectEndpoint),
);
request.files.add(
http.MultipartFile.fromBytes(
'file',
bytes,
filename: 'image.jpg',
),
);
var streamedResponse = await request.send();
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
var decodedResponse = json.decode(response.body);
var detections = decodedResponse['detections'] as List;
if (detections.isNotEmpty) {
var highestConfidenceDetection = detections.reduce((curr, next) =>
(curr['confidence'] as double) > (next['confidence'] as double) ? curr : next);
String gesture = highestConfidenceDetection['class'];
double confidence = highestConfidenceDetection['confidence'] * 100;
if (confidence >= AppConfig.confidenceThreshold) {
if (gesture == 'hi' && mounted) {
_timer?.cancel();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => InstructionsScreen(
camera: widget.camera,
sharedController: sharedController,
),
),
);
}
}
}
}
} catch (e) {
print('Error during gesture detection: $e');
} finally {
_isProcessing = false;
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// Always use Image.asset for the background GIF
Image.asset(
AppConfig.welcomeBackgroundPath,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) {
print('Error loading background: $error');
// Fallback to bundled asset
return Image.asset(
AppConfig.welcomeBackgroundPath,
fit: BoxFit.cover,
gaplessPlayback: true,
);
},
),
// Semi-transparent overlay for better readability
Container(
color: Colors.black.withOpacity(0.4),
),
// Main content
SafeArea(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Calculate responsive font size based on screen width
double fontSize = constraints.maxWidth * 0.04; // 4% of screen width
return Padding(
padding: EdgeInsets.symmetric(
horizontal: constraints.maxWidth * 0.1, // 10% padding
),
child: Text(
'Imita el gesto para empezar...',
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
shadows: [
Shadow(
offset: const Offset(2.0, 2.0),
blurRadius: 3.0,
color: Colors.black.withOpacity(0.5),
),
],
),
textAlign: TextAlign.center,
),
);
},
),
),
),
],
),
);
}
}
This is the app_config:
// lib/config/app_config.dart
import 'dart:io';
import 'dart:convert';
import 'package:camera/camera.dart';
class AppConfig {
// Default background media paths
// These point to the assets bundled with the app
static String welcomeBackgroundPath = 'lib/assets/videos/main_view.gif';
static String defaultBackgroundPath = 'lib/assets/videos/bg_view.gif';
static String loadingVideoPath = 'lib/assets/videos/ad_view.gif';
// Flags to track if we're using downloaded assets vs bundled assets
static bool usingDownloadedMainView = false;
static bool usingDownloadedBgView = false;
static bool usingDownloadedAdView = false;
// API endpoints
static const String baseApiUrl = 'http://localhost:8000';
static const String detectEndpoint = '$baseApiUrl/detect';
static const String uploadImageEndpoint = '$baseApiUrl/upload-image';
static const String uploadImageS3Endpoint = '$baseApiUrl/upload-image-s3';
static const String generateImageEndpoint = '$baseApiUrl/generate-image';
// Gesture detection settings
static const double confidenceThreshold = 77.0;
static const Duration gestureDetectionInterval = Duration(milliseconds: 350);
// Camera settings
static const ResolutionPreset defaultCameraResolution = ResolutionPreset.max;
static const bool enableAudio = false;
// Initialize app configuration by checking for appearance config
static Future<void> initAppConfig() async {
try {
// Try to load appearance_config.json first
final currentDir = Directory.current;
final String configPath = '${currentDir.path}/appearance_config.json';
final configFile = File(configPath);
print('Checking for appearance config at: $configPath');
if (await configFile.exists()) {
try {
final String configContent = await configFile.readAsString();
final Map<String, dynamic> config = jsonDecode(configContent);
if (config.containsKey('appearance')) {
final appearance = config['appearance'];
// Check main_view GIF - should now be an asset path like "videos/main_view.gif"
final String? mainViewPath = appearance['main_view'];
if (mainViewPath != null && mainViewPath.isNotEmpty && mainViewPath.startsWith('videos/')) {
welcomeBackgroundPath = mainViewPath;
usingDownloadedMainView = true;
print('Using downloaded main_view GIF: $mainViewPath');
}
// Check bg_view GIF
final String? bgViewPath = appearance['bg_view'];
if (bgViewPath != null && bgViewPath.isNotEmpty && bgViewPath.startsWith('videos/')) {
defaultBackgroundPath = bgViewPath;
usingDownloadedBgView = true;
print('Using downloaded bg_view GIF: $bgViewPath');
}
// Check ad_view GIF
final String? adViewPath = appearance['ad_view'];
if (adViewPath != null && adViewPath.isNotEmpty && adViewPath.startsWith('videos/')) {
loadingVideoPath = adViewPath;
usingDownloadedAdView = true;
print('Using downloaded ad_view GIF: $adViewPath');
}
}
} catch (e) {
print('Error parsing appearance_config.json: $e');
}
} else {
print('No appearance_config.json found at $configPath');
}
// Log final configuration
print('AppConfig initialized:');
print('- Welcome background: $welcomeBackgroundPath (downloaded: $usingDownloadedMainView)');
print('- Default background: $defaultBackgroundPath (downloaded: $usingDownloadedBgView)');
print('- Loading video: $loadingVideoPath (downloaded: $usingDownloadedAdView)');
} catch (e) {
print('Error initializing app config: $e');
// Keep using default paths on error
}
}
}
// Gesture mappings and their actions
class GestureConfig {
// Photo Review Screen gestures
static const photoReviewGestures = {
'thumbsup': GestureAction.usePhoto,
'thumbsdown': GestureAction.retakePhoto,
};
// Welcome Screen gestures
static const welcomeScreenGestures = {
'hi': GestureAction.startApp,
};
// Option Screen gestures
static const optionScreenGestures = {
'fist': GestureAction.selectOption1,
'peace': GestureAction.selectOption2,
'heart': GestureAction.showGuide,
};
// Results Screen gestures
static const resultScreenGestures = {
'thumbsup': GestureAction.generateQR,
'thumbsdown': GestureAction.regenerate,
'fist': GestureAction.goBack,
};
}
enum GestureAction {
usePhoto,
retakePhoto,
startApp,
selectOption1,
selectOption2,
showGuide,
generateQR,
regenerate,
goBack,
}
This is a sample response of the update system:
{
"appearance": {
"main_view": "lib/assets/videos/main_view.gif",
"bg_view": "lib/assets/videos/bg_view.gif",
"ad_view": "lib/assets/videos/ad_view.gif",
"timestamp": "2025-03-27T09:21:21.357Z"
}
}