diff --git a/lib/bademagic_module/utils/file_helper.dart b/lib/bademagic_module/utils/file_helper.dart index ce84a2292..9c4d23f8d 100644 --- a/lib/bademagic_module/utils/file_helper.dart +++ b/lib/bademagic_module/utils/file_helper.dart @@ -38,6 +38,12 @@ class FileHelper { return 'data_${timestamp}_$uniqueId.json'; } + static String _generateFramesFilename() { + final String uniqueId = uuid.v4(); + final String timestamp = DateTime.now().millisecondsSinceEpoch.toString(); + return 'frames_${timestamp}_$uniqueId.json'; + } + // Add a new image to the cache void addToCache(Uint8List imageData, String filename) { int key; @@ -189,6 +195,31 @@ class FileHelper { await _addImageDataToCache(image, filename); } + Future saveFrameAnimation(List>> frames) async { + final filename = _generateFramesFilename(); + final jsonData = jsonEncode(frames); + await _writeToFile(filename, jsonData); + } + + Future saveFrameAnimationWithName( + String name, List>> frames, int speed) async { + // Sanitize name for filesystem + String sanitized = name + .trim() + .replaceAll(RegExp(r"[^A-Za-z0-9 _.-]"), "") + .replaceAll(" ", "_"); + if (sanitized.isEmpty) { + sanitized = "untitled"; + } + final filename = 'frames_${sanitized}.json'; + final Map payload = { + 'frames': frames, + 'speed': speed, + }; + final jsonData = jsonEncode(payload); + await _writeToFile(filename, jsonData); + } + Future readFromFile(String filename) async { try { final path = await _getFilePath(filename); diff --git a/lib/main.dart b/lib/main.dart index 7ea48008c..0cdaa4853 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,8 @@ import 'package:badgemagic/providers/getitlocator.dart'; import 'package:badgemagic/providers/imageprovider.dart'; import 'package:badgemagic/view/about_us_screen.dart'; import 'package:badgemagic/view/draw_badge_screen.dart'; +import 'package:badgemagic/view/create_frames_screen.dart'; +import 'package:badgemagic/view/saved_frames_screen.dart'; import 'package:badgemagic/view/homescreen.dart'; import 'package:badgemagic/view/save_badge_screen.dart'; import 'package:badgemagic/view/saved_clipart.dart'; @@ -51,6 +53,8 @@ class MyApp extends StatelessWidget { '/savedClipart': (context) => const SavedClipart(), '/aboutUs': (context) => const AboutUsScreen(), '/settings': (context) => const SettingsScreen(), + '/createFrames': (context) => const CreateFramesScreen(), + '/savedFrames': (context) => const SavedFramesScreen(), }, ); }, diff --git a/lib/view/create_frames_screen.dart b/lib/view/create_frames_screen.dart new file mode 100644 index 000000000..85f2f0182 --- /dev/null +++ b/lib/view/create_frames_screen.dart @@ -0,0 +1,483 @@ +import 'package:badgemagic/bademagic_module/utils/file_helper.dart'; +import 'package:badgemagic/bademagic_module/utils/toast_utils.dart'; +import 'package:badgemagic/providers/draw_badge_provider.dart'; +import 'package:badgemagic/view/widgets/common_scaffold_widget.dart'; +import 'package:badgemagic/virtualbadge/view/draw_badge.dart' as vb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:badgemagic/providers/animation_badge_provider.dart'; +import 'package:badgemagic/virtualbadge/view/animated_badge.dart'; + +class CreateFramesScreen extends StatefulWidget { + const CreateFramesScreen({super.key}); + + @override + State createState() => _CreateFramesScreenState(); +} + +class _CreateFramesScreenState extends State { + DrawBadgeProvider? _drawProvider; + final int _rows = 11; + final int _cols = 44; + final int _totalFrames = 8; + int _currentFrame = 0; + final FileHelper _fileHelper = FileHelper(); + final List>> _history = []; + int _historyIndex = -1; + late List>> _frames; + + @override + void initState() { + super.initState(); + _frames = List.generate(_totalFrames, + (_) => List.generate(_rows, (_) => List.filled(_cols, 0))); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _setLandscapeOrientation(); + } + + @override + void dispose() { + _resetPortraitOrientation(); + if (_drawProvider != null) { + _drawProvider!.removeListener(_onProviderChanged); + } + super.dispose(); + } + + void _resetPortraitOrientation() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + void _setLandscapeOrientation() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + } + + void _onProviderInit(DrawBadgeProvider provider) { + _drawProvider = provider; + _drawProvider!.setShape(DrawShape.freehand); + _applyFrameToProvider(_currentFrame); + _pushHistorySnapshot(_drawProvider!.getDrawViewGrid()); + _drawProvider!.addListener(_onProviderChanged); + } + + void _onProviderChanged() { + final provider = _drawProvider; + if (provider == null) return; + final current = provider.getDrawViewGrid(); + _pushHistorySnapshot(current); + if (mounted) setState(() {}); + } + + void _pushHistorySnapshot(List> grid) { + if (_historyIndex >= 0) { + final last = _history[_historyIndex]; + if (_gridsEqual(last, grid)) return; + } + if (_historyIndex < _history.length - 1) { + _history.removeRange(_historyIndex + 1, _history.length); + } + _history.add(_cloneGrid(grid)); + _historyIndex = _history.length - 1; + } + + bool _gridsEqual(List> a, List> b) { + if (a.length != b.length || a[0].length != b[0].length) return false; + for (int i = 0; i < a.length; i++) { + for (int j = 0; j < a[i].length; j++) { + if (a[i][j] != b[i][j]) return false; + } + } + return true; + } + + List> _cloneGrid(List> src) { + return List.generate(src.length, (i) => List.from(src[i])); + } + + List> _currentProviderGrid() { + if (_drawProvider == null) { + return List.generate(_rows, (_) => List.filled(_cols, false)); + } + return _drawProvider!.getDrawViewGrid(); + } + + void _applyFrameToProvider(int frameIndex) { + if (_drawProvider == null) return; + final data = _frames[frameIndex]; + _drawProvider!.resetDrawViewGrid(); + for (int r = 0; r < _rows; r++) { + for (int c = 0; c < _cols; c++) { + if (data[r][c] == 1) { + _drawProvider!.setCell(r, c, true, preview: false); + } + } + } + _drawProvider!.commitGridUpdate(); + _history.clear(); + _historyIndex = -1; + _pushHistorySnapshot(_drawProvider!.getDrawViewGrid()); + } + + void _storeProviderIntoFrame(int frameIndex) { + final grid = _currentProviderGrid(); + for (int r = 0; r < _rows; r++) { + for (int c = 0; c < _cols; c++) { + _frames[frameIndex][r][c] = grid[r][c] ? 1 : 0; + } + } + } + + void _nextFrame() { + _storeProviderIntoFrame(_currentFrame); + setState(() { + _currentFrame = (_currentFrame + 1).clamp(0, _totalFrames - 1); + }); + _applyFrameToProvider(_currentFrame); + } + + void _prevFrame() { + _storeProviderIntoFrame(_currentFrame); + setState(() { + _currentFrame = (_currentFrame - 1).clamp(0, _totalFrames - 1); + }); + _applyFrameToProvider(_currentFrame); + } + + void _undo() { + if (_historyIndex > 0) { + _historyIndex--; + _drawProvider?.updateDrawViewGrid(_history[_historyIndex]); + } + } + + void _redo() { + if (_historyIndex < _history.length - 1) { + _historyIndex++; + _drawProvider?.updateDrawViewGrid(_history[_historyIndex]); + } + } + + Future _saveFrames() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + _storeProviderIntoFrame(_currentFrame); + + int selectedSpeed = 1; + final nameController = TextEditingController(); + final height = _frames.first.length; + final width = _frames.first.first.length; + final stitched = List.generate( + height, (_) => List.filled(width * _frames.length, false)); + for (int fi = 0; fi < _frames.length; fi++) { + for (int r = 0; r < height; r++) { + for (int c = 0; c < width; c++) { + stitched[r][fi * width + c] = _frames[fi][r][c] == 1; + } + } + } + + final previewProvider = AnimationBadgeProvider(); + previewProvider.setNewGrid(stitched); + previewProvider.calculateDuration(selectedSpeed); + previewProvider.setAnimationMode(animationMap[0]); + + final name = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Save Frames'), + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Animation name', + hintText: 'e.g. MyAnimation', + ), + ), + const SizedBox(height: 12), + const Text('Speed'), + DropdownButton( + isExpanded: true, + value: selectedSpeed, + items: const [ + DropdownMenuItem(value: 1, child: Text('1 - Slowest')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + DropdownMenuItem(value: 4, child: Text('4')), + DropdownMenuItem(value: 5, child: Text('5')), + DropdownMenuItem(value: 6, child: Text('6')), + DropdownMenuItem(value: 7, child: Text('7')), + DropdownMenuItem(value: 8, child: Text('8 - Fastest')), + ], + onChanged: (v) { + if (v != null) { + setState(() => selectedSpeed = v); + previewProvider.calculateDuration(v); + } + }, + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: ChangeNotifierProvider.value( + value: previewProvider, + child: const AnimationBadge(), + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, nameController.text), + child: const Text('Save'), + ), + ], + ), + ), + ); + + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + + if (name == null || name.isEmpty) return; + + await _fileHelper.saveFrameAnimationWithName(name, _frames, selectedSpeed); + ToastUtils().showToast("Frames saved"); + } + + bool _isCurrentFrameEmpty() { + final grid = _drawProvider?.getDrawViewGrid(); + if (grid == null) return true; + for (var row in grid) { + for (var cell in row) { + if (cell) return false; + } + } + return true; + } + + Widget _buildCompactButton(bool isDraw, IconData icon, String label) { + final isSelected = (_drawProvider?.getIsDrawing() ?? true) == isDraw; + return TextButton( + onPressed: _drawProvider == null + ? null + : () { + setState(() { + _drawProvider!.toggleIsDrawing(isDraw); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + minimumSize: const Size(60, 40), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: isSelected ? Colors.blue : Colors.black, size: 20), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.black, + fontSize: 10)), + ], + ), + ); + } + + Widget _buildResetButton() { + return TextButton( + onPressed: _drawProvider == null + ? null + : () { + setState(() { + _drawProvider!.resetDrawViewGrid(); + }); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + minimumSize: const Size(60, 40), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.refresh, color: Colors.black, size: 20), + SizedBox(height: 2), + Text('Reset', style: TextStyle(color: Colors.black, fontSize: 10)), + ], + ), + ); + } + + Widget _buildUndoButton() { + return TextButton( + onPressed: _drawProvider == null ? null : _undo, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + minimumSize: const Size(60, 40), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.undo, color: Colors.black, size: 20), + SizedBox(height: 2), + Text('Undo', style: TextStyle(color: Colors.black, fontSize: 10)), + ], + ), + ); + } + + Widget _buildRedoButton() { + return TextButton( + onPressed: _drawProvider == null ? null : _redo, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + minimumSize: const Size(60, 40), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.redo, color: Colors.black, size: 20), + SizedBox(height: 2), + Text('Redo', style: TextStyle(color: Colors.black, fontSize: 10)), + ], + ), + ); + } + + Widget _buildSaveButton() { + return TextButton( + onPressed: _currentFrame == _totalFrames - 1 ? _saveFrames : null, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + minimumSize: const Size(60, 40), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save, color: Colors.black, size: 20), + SizedBox(height: 2), + Text('Save', style: TextStyle(color: Colors.black, fontSize: 10)), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + _resetPortraitOrientation(); + return true; + }, + child: CommonScaffold( + index: 1, + title: 'Create Frames', + body: Column( + children: [ + const SizedBox(height: 8), + Expanded( + flex: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + onPressed: _prevFrame, + icon: const Icon(Icons.chevron_left), + color: Colors.grey, + iconSize: 28, + ), + Expanded( + child: AspectRatio( + aspectRatio: 3.2, + child: Stack( + alignment: Alignment.center, + children: [ + vb.BMBadge( + providerInit: _onProviderInit, + ), + if (_isCurrentFrameEmpty()) + IgnorePointer( + ignoring: true, + child: Container( + alignment: Alignment.center, + child: Text( + 'Frame ${_currentFrame + 1}', + style: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ), + ], + ), + ), + ), + IconButton( + onPressed: _nextFrame, + icon: const Icon(Icons.chevron_right), + color: Colors.grey, + iconSize: 28, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 6, + children: [ + _buildCompactButton(true, Icons.edit, 'Draw'), + _buildCompactButton(false, Icons.delete, 'Erase'), + _buildResetButton(), + _buildUndoButton(), + _buildRedoButton(), + _buildSaveButton(), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/lib/view/saved_frames_screen.dart b/lib/view/saved_frames_screen.dart new file mode 100644 index 000000000..eaf33302f --- /dev/null +++ b/lib/view/saved_frames_screen.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:badgemagic/providers/animation_badge_provider.dart'; +import 'package:badgemagic/view/widgets/common_scaffold_widget.dart'; +import 'package:badgemagic/virtualbadge/view/animated_badge.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; +import 'package:provider/provider.dart'; + +class SavedFramesScreen extends StatefulWidget { + const SavedFramesScreen({super.key}); + + @override + State createState() => _SavedFramesScreenState(); +} + +class _SavedFramesScreenState extends State { + List>>>> _saved = []; + + Future _load() async { + final dir = await getApplicationDocumentsDirectory(); + final files = dir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.json') && f.path.contains('frames_')) + .toList(); + List>>>> items = []; + for (final f in files) { + try { + final content = await f.readAsString(); + final data = jsonDecode(content); + List>> frames; + if (data is List) { + // backward compatibility (no speed) + frames = (data) + .map((frame) => (frame as List) + .map((row) => (row as List).cast()) + .toList()) + .toList(); + } else { + frames = (data['frames'] as List) + .map((frame) => (frame as List) + .map((row) => (row as List).cast()) + .toList()) + .toList(); + } + items.add(MapEntry(f.uri.pathSegments.last, frames)); + } catch (_) {} + } + setState(() => _saved = items); + } + + @override + void initState() { + super.initState(); + _load(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => AnimationBadgeProvider(), + child: CommonScaffold( + index: 4, + title: 'Saved Frame Animation', + body: Column( + children: [ + const SizedBox(height: 8), + const AnimationBadge(), + Expanded( + child: ListView.builder( + itemCount: _saved.length, + itemBuilder: (context, index) { + final item = _saved[index]; + return Container( + width: 370.w, + padding: EdgeInsets.all(6.dg), + margin: EdgeInsets.all(10.dg), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6.dg), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Padding( + padding: EdgeInsets.only(right: 8.w), + child: Text( + item.key + .replaceAll('frames_', '') + .replaceAll('.json', ''), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () async { + final provider = + context.read(); + final frames = item.value; + final height = frames.first.length; + final width = frames.first.first.length; + final stitched = List.generate( + height, + (_) => List.filled( + width * frames.length, false)); + for (int fi = 0; fi < frames.length; fi++) { + for (int r = 0; r < height; r++) { + for (int c = 0; c < width; c++) { + stitched[r][fi * width + c] = + frames[fi][r][c] == 1; + } + } + } + provider.setNewGrid(stitched); + // read speed if present + int playSpeed = 1; + try { + final dir = + await getApplicationDocumentsDirectory(); + final path = '${dir.path}/${item.key}'; + final jsonStr = + await File(path).readAsString(); + final parsed = jsonDecode(jsonStr); + if (parsed is Map && + parsed['speed'] is int) { + playSpeed = parsed['speed']; + } + } catch (_) {} + provider.calculateDuration(playSpeed); + provider.setAnimationMode(animationMap[0]); + }, + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () async { + final dir = + await getApplicationDocumentsDirectory(); + final path = '${dir.path}/${item.key}'; + final f = File(path); + if (await f.exists()) await f.delete(); + await _load(); + }, + ), + ], + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/widgets/navigation_drawer.dart b/lib/view/widgets/navigation_drawer.dart index b9b051107..8bbebd8df 100644 --- a/lib/view/widgets/navigation_drawer.dart +++ b/lib/view/widgets/navigation_drawer.dart @@ -59,30 +59,42 @@ class _BMDrawerState extends State { ), _buildListTile( index: 1, + icon: Icons.movie_filter_outlined, + title: 'Create Your Own Frames', + routeName: '/createFrames', + ), + _buildListTile( + index: 2, assetIcon: "assets/icons/signature.png", title: 'Draw Clipart', routeName: '/drawBadge', ), _buildListTile( - index: 2, + index: 3, assetIcon: "assets/icons/r_save.png", title: 'Saved Badges', routeName: '/savedBadge', ), _buildListTile( - index: 3, + index: 4, + icon: Icons.collections_bookmark_outlined, + title: 'Saved Frame Animation', + routeName: '/savedFrames', + ), + _buildListTile( + index: 5, assetIcon: "assets/icons/r_save.png", title: 'Saved Cliparts', routeName: '/savedClipart', ), _buildListTile( - index: 4, + index: 6, assetIcon: "assets/icons/setting.png", title: 'Settings', routeName: '/settings', ), _buildListTile( - index: 5, + index: 7, assetIcon: "assets/icons/r_team.png", title: 'About Us', routeName: '/aboutUs', @@ -100,14 +112,14 @@ class _BMDrawerState extends State { ), ), _buildListTile( - index: 6, + index: 8, assetIcon: "assets/icons/r_price.png", title: 'Buy Badge', routeName: '/buyBadge', externalLink: 'https://badgemagic.fossasia.org/shop/', ), _buildListTile( - index: 7, + index: 9, icon: Icons.share, title: 'Share', routeName: '/share', @@ -115,7 +127,7 @@ class _BMDrawerState extends State { 'Badge Magic is an app to control LED name badges. This app provides features to portray names, graphics and simple animations on LED badges.You can also download it from below link https://play.google.com/store/apps/details?id=org.fossasia.badgemagic', ), _buildListTile( - index: 8, + index: 10, icon: Icons.star, title: 'Rate Us', routeName: '/rateUs', @@ -124,14 +136,14 @@ class _BMDrawerState extends State { : 'https://play.google.com/store/apps/details?id=org.fossasia.badgemagic', ), _buildListTile( - index: 9, + index: 11, assetIcon: "assets/icons/r_virus.png", title: 'Feedback/Bug Reports', routeName: '/feedback', externalLink: 'https://github.com/fossasia/badgemagic-app/issues', ), _buildListTile( - index: 10, + index: 12, assetIcon: "assets/icons/r_insurance.png", title: 'Privacy Policy', routeName: '/privacyPolicy',