Skip to content

Commit 01baf74

Browse files
committed
feat: Add badge rename and fix long-press multi-select
1 parent 78c1d73 commit 01baf74

6 files changed

Lines changed: 312 additions & 27 deletions

File tree

lib/bademagic_module/utils/file_helper.dart

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,53 @@ class FileHelper {
463463
}
464464
}
465465

466+
/// Renames a saved badge file.
467+
///
468+
/// [oldFilename] must include the `.json` extension (e.g. `MyBadge.json`).
469+
/// [newName] is the bare display name WITHOUT extension (e.g. `NewName`).
470+
///
471+
/// Returns `true` on success.
472+
/// Returns `false` if [newName] already exists on disk, or on any error.
473+
Future<bool> renameBadge(String oldFilename, String newName) async {
474+
try {
475+
final directory = await getApplicationDocumentsDirectory();
476+
final oldPath = '${directory.path}/$oldFilename';
477+
final newFilename = '$newName.json';
478+
final newPath = '${directory.path}/$newFilename';
479+
480+
final oldFile = File(oldPath);
481+
if (!await oldFile.exists()) {
482+
logger.w('renameBadge: source file not found: $oldPath');
483+
return false;
484+
}
485+
if (await File(newPath).exists()) {
486+
logger.w('renameBadge: a badge with that name already exists: $newPath');
487+
return false;
488+
}
489+
490+
// Rename the physical file on disk
491+
await oldFile.rename(newPath);
492+
logger.i('Renamed badge on disk: $oldFilename → $newFilename');
493+
494+
// Migrate the original-text entry in BadgeTextStorage
495+
await BadgeTextStorage.moveOriginalText(oldFilename, newFilename);
496+
497+
// Update the in-memory savedBadgeCache so the UI reflects the new name
498+
// immediately without requiring a full reload
499+
final cache = imageCacheProvider.savedBadgeCache;
500+
final idx = cache.indexWhere((e) => e.key == oldFilename);
501+
if (idx >= 0) {
502+
cache[idx] = MapEntry(newFilename, cache[idx].value);
503+
}
504+
imageCacheProvider.notify();
505+
506+
return true;
507+
} catch (e) {
508+
logger.e('Error renaming badge: $e');
509+
return false;
510+
}
511+
}
512+
466513
Future<void> saveImageWithName(
467514
List<List<bool>> imageData, String customName) async {
468515
List<List<int>> image = List.generate(
@@ -522,7 +569,7 @@ class FileHelper {
522569
return candidate;
523570
}
524571

525-
Future<bool> importBadgeData(context) async {
572+
Future<bool> importBadgeData(BuildContext context) async {
526573
try {
527574
// Open file picker to select a JSON file
528575
FilePickerResult? result = await FilePicker.platform.pickFiles(
@@ -572,9 +619,11 @@ class FileHelper {
572619
throw Exception('Only .gif and .json are supported!');
573620
}
574621
} catch (e) {
575-
ScaffoldMessenger.of(context).showSnackBar(
576-
SnackBar(content: Text('Error importing badge: $e')),
577-
);
622+
if (context.mounted) {
623+
ScaffoldMessenger.of(context).showSnackBar(
624+
SnackBar(content: Text('Error importing badge: $e')),
625+
);
626+
}
578627
return false;
579628
}
580629
}

lib/bademagic_module/utils/image_utils.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class ImageUtils {
9898
pixelArray[y][x] = color;
9999
} else {
100100
// Handle out-of-bounds case gracefully, e.g., fill with a default color
101-
pixelArray[y][x] = Colors.transparent.value;
101+
pixelArray[y][x] = Colors.transparent.toARGB32();
102102
}
103103
}
104104
}

lib/providers/saved_badge_provider.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'package:badgemagic/bademagic_module/models/data.dart';
2-
import 'dart:convert';
32
import 'dart:io';
43
import 'package:badgemagic/bademagic_module/models/messages.dart';
54
import 'package:badgemagic/bademagic_module/models/mode.dart';

lib/view/widgets/clipart_list_view.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class SavedClipartListView extends StatelessWidget {
4545
borderRadius: BorderRadius.circular(15.dg),
4646
boxShadow: [
4747
BoxShadow(
48-
color: Colors.grey.withOpacity(0.5),
48+
color: Colors.grey.withValues(alpha: 0.5),
4949
spreadRadius: 2,
5050
blurRadius: 5,
5151
offset: const Offset(0, 3),
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import 'dart:io';
2+
3+
import 'package:badgemagic/bademagic_module/utils/file_helper.dart';
4+
import 'package:badgemagic/bademagic_module/utils/toast_utils.dart';
5+
import 'package:badgemagic/constants.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_screenutil/flutter_screenutil.dart';
8+
import 'package:path_provider/path_provider.dart';
9+
10+
/// A dialog that allows the user to rename an existing saved badge.
11+
///
12+
/// Pre-fills the text field with the current badge name (stripped of `.json`).
13+
/// Validates that the new name is not empty and does not already exist on disk.
14+
/// On success, calls [refreshBadgesCallback] so the list updates immediately.
15+
class RenameBadgeDialog extends StatefulWidget {
16+
/// The current filename including the `.json` extension, e.g. `MyBadge.json`.
17+
final String currentFilename;
18+
19+
/// The full badge data entry (needed to pass back to [refreshBadgesCallback]).
20+
final MapEntry<String, Map<String, dynamic>> badgeData;
21+
22+
/// Called after a successful rename so the parent list view refreshes.
23+
final Future<void> Function(MapEntry<String, Map<String, dynamic>>)
24+
refreshBadgesCallback;
25+
26+
const RenameBadgeDialog({
27+
super.key,
28+
required this.currentFilename,
29+
required this.badgeData,
30+
required this.refreshBadgesCallback,
31+
});
32+
33+
@override
34+
State<RenameBadgeDialog> createState() => _RenameBadgeDialogState();
35+
}
36+
37+
class _RenameBadgeDialogState extends State<RenameBadgeDialog> {
38+
late final TextEditingController _nameController;
39+
String? _errorText;
40+
bool _isLoading = false;
41+
42+
@override
43+
void initState() {
44+
super.initState();
45+
// Pre-fill with the current name, stripping the `.json` extension
46+
final currentName = widget.currentFilename.endsWith('.json')
47+
? widget.currentFilename.substring(
48+
0, widget.currentFilename.length - 5)
49+
: widget.currentFilename;
50+
_nameController = TextEditingController(text: currentName);
51+
// Select all text so the user can immediately type a new name
52+
_nameController.selection =
53+
TextSelection(baseOffset: 0, extentOffset: currentName.length);
54+
}
55+
56+
@override
57+
void dispose() {
58+
_nameController.dispose();
59+
super.dispose();
60+
}
61+
62+
Future<void> _onRename() async {
63+
final newName = _nameController.text.trim();
64+
65+
// --- Validation ---
66+
if (newName.isEmpty) {
67+
setState(() => _errorText = 'Badge name cannot be empty.');
68+
return;
69+
}
70+
71+
// No-op if the name hasn't changed
72+
final currentName = widget.currentFilename.endsWith('.json')
73+
? widget.currentFilename.substring(
74+
0, widget.currentFilename.length - 5)
75+
: widget.currentFilename;
76+
if (newName == currentName) {
77+
Navigator.of(context).pop();
78+
return;
79+
}
80+
81+
// Check for name collision on disk
82+
final directory = await getApplicationDocumentsDirectory();
83+
final newPath = '${directory.path}/$newName.json';
84+
if (await File(newPath).exists()) {
85+
setState(
86+
() => _errorText = 'A badge with that name already exists.');
87+
return;
88+
}
89+
90+
setState(() {
91+
_errorText = null;
92+
_isLoading = true;
93+
});
94+
95+
final success =
96+
await FileHelper().renameBadge(widget.currentFilename, newName);
97+
98+
if (!mounted) return;
99+
setState(() => _isLoading = false);
100+
101+
if (success) {
102+
ToastUtils().showToast('Badge renamed successfully!');
103+
// Build a synthetic entry with the new key so the list can refresh
104+
final updatedEntry =
105+
MapEntry('$newName.json', widget.badgeData.value);
106+
await widget.refreshBadgesCallback(updatedEntry);
107+
if (mounted) Navigator.of(context).pop();
108+
} else {
109+
setState(
110+
() => _errorText = 'Could not rename the badge. Please try again.');
111+
}
112+
}
113+
114+
@override
115+
Widget build(BuildContext context) {
116+
return Dialog(
117+
shape: RoundedRectangleBorder(
118+
borderRadius: BorderRadius.circular(5.r),
119+
),
120+
child: Container(
121+
padding:
122+
EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h),
123+
width: 300.w,
124+
child: Column(
125+
mainAxisSize: MainAxisSize.min,
126+
crossAxisAlignment: CrossAxisAlignment.start,
127+
children: [
128+
Text(
129+
'Rename Badge',
130+
style: TextStyle(
131+
fontSize: 20.sp,
132+
fontWeight: FontWeight.bold,
133+
),
134+
),
135+
SizedBox(height: 12.h),
136+
Text(
137+
'New name',
138+
style: TextStyle(
139+
fontWeight: FontWeight.w400,
140+
color: colorPrimary,
141+
fontSize: 13.sp,
142+
),
143+
),
144+
SizedBox(height: 6.h),
145+
TextField(
146+
controller: _nameController,
147+
autofocus: true,
148+
maxLength: 200,
149+
onChanged: (_) {
150+
if (_errorText != null) {
151+
setState(() => _errorText = null);
152+
}
153+
},
154+
decoration: InputDecoration(
155+
counterText: '',
156+
enabledBorder: UnderlineInputBorder(
157+
borderSide: BorderSide(color: colorPrimary),
158+
),
159+
focusedBorder: UnderlineInputBorder(
160+
borderSide: BorderSide(color: colorPrimary, width: 2),
161+
),
162+
),
163+
),
164+
// Inline error message
165+
if (_errorText != null) ...[
166+
SizedBox(height: 4.h),
167+
Text(
168+
_errorText!,
169+
style: TextStyle(
170+
color: Colors.red,
171+
fontSize: 12.sp,
172+
),
173+
),
174+
],
175+
SizedBox(height: 12.h),
176+
Row(
177+
mainAxisAlignment: MainAxisAlignment.end,
178+
children: [
179+
TextButton(
180+
onPressed:
181+
_isLoading ? null : () => Navigator.of(context).pop(),
182+
child: Text(
183+
'Cancel',
184+
style: TextStyle(color: colorPrimary),
185+
),
186+
),
187+
TextButton(
188+
onPressed: _isLoading ? null : _onRename,
189+
child: _isLoading
190+
? SizedBox(
191+
width: 16.w,
192+
height: 16.h,
193+
child: CircularProgressIndicator(
194+
strokeWidth: 2,
195+
color: colorPrimary,
196+
),
197+
)
198+
: Text(
199+
'Rename',
200+
style: TextStyle(color: colorPrimary),
201+
),
202+
),
203+
],
204+
),
205+
],
206+
),
207+
),
208+
);
209+
}
210+
}

0 commit comments

Comments
 (0)