1use dioxus::prelude::*;
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum QRCodeType {
10 #[default]
12 Svg,
13 Canvas,
15}
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum QRCodeStatus {
20 #[default]
22 Active,
23 Expired,
25 Loading,
27 Scanned,
29}
30
31impl QRCodeStatus {
32 fn as_class(&self) -> &'static str {
33 match self {
34 QRCodeStatus::Active => "adui-qrcode-active",
35 QRCodeStatus::Expired => "adui-qrcode-expired",
36 QRCodeStatus::Loading => "adui-qrcode-loading",
37 QRCodeStatus::Scanned => "adui-qrcode-scanned",
38 }
39 }
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum QRCodeErrorLevel {
45 L,
47 #[default]
49 M,
50 Q,
52 H,
54}
55
56#[derive(Props, Clone, PartialEq)]
58pub struct QRCodeProps {
59 pub value: String,
61
62 #[props(default)]
64 pub r#type: QRCodeType,
65
66 #[props(default = 160)]
68 pub size: u32,
69
70 #[props(optional)]
72 pub icon: Option<String>,
73
74 #[props(optional)]
76 pub icon_size: Option<u32>,
77
78 #[props(optional)]
80 pub color: Option<String>,
81
82 #[props(optional)]
84 pub bg_color: Option<String>,
85
86 #[props(default)]
88 pub error_level: QRCodeErrorLevel,
89
90 #[props(default)]
92 pub status: QRCodeStatus,
93
94 #[props(default = true)]
96 pub bordered: bool,
97
98 #[props(optional)]
100 pub on_refresh: Option<EventHandler<()>>,
101
102 #[props(optional)]
104 pub class: Option<String>,
105
106 #[props(optional)]
108 pub root_class: Option<String>,
109
110 #[props(optional)]
112 pub style: Option<String>,
113}
114
115#[component]
129pub fn QRCode(props: QRCodeProps) -> Element {
130 let QRCodeProps {
131 value,
132 r#type,
133 size,
134 icon,
135 icon_size,
136 color,
137 bg_color,
138 error_level,
139 status,
140 bordered,
141 on_refresh,
142 class,
143 root_class,
144 style,
145 } = props;
146
147 if value.is_empty() {
149 return rsx! {};
150 }
151
152 let fg_color = color.unwrap_or_else(|| "currentColor".to_string());
153 let bg_color = bg_color.unwrap_or_else(|| "transparent".to_string());
154 let icon_size = icon_size.unwrap_or(40);
155
156 let mut class_list = vec!["adui-qrcode".to_string()];
158 class_list.push(status.as_class().to_string());
159 if !bordered {
160 class_list.push("adui-qrcode-borderless".to_string());
161 }
162 if let Some(extra) = class {
163 class_list.push(extra);
164 }
165 let class_attr = class_list.join(" ");
166
167 let mut root_class_list = vec!["adui-qrcode-wrapper".to_string()];
168 if let Some(extra) = root_class {
169 root_class_list.push(extra);
170 }
171 let root_class_attr = root_class_list.join(" ");
172
173 let container_style = format!(
174 "width: {}px; height: {}px; background-color: {}; {}",
175 size,
176 size,
177 bg_color,
178 style.unwrap_or_default()
179 );
180
181 let qr_modules = generate_qr_modules(&value, error_level);
183 let module_count = qr_modules.len();
184
185 let qr_content = match r#type {
187 QRCodeType::Svg => {
188 render_svg_qrcode(&qr_modules, module_count, size, &fg_color, &icon, icon_size)
189 }
190 QRCodeType::Canvas => {
191 render_svg_qrcode(&qr_modules, module_count, size, &fg_color, &icon, icon_size)
193 }
194 };
195
196 let status_overlay = match status {
198 QRCodeStatus::Active => None,
199 QRCodeStatus::Expired => {
200 let handler = on_refresh;
201 Some(rsx! {
202 div { class: "adui-qrcode-cover",
203 div { class: "adui-qrcode-status",
204 span { class: "adui-qrcode-status-text", "QR code expired" }
205 button {
206 class: "adui-qrcode-refresh-btn",
207 onclick: move |_| {
208 if let Some(cb) = handler.as_ref() {
209 cb.call(());
210 }
211 },
212 "Refresh"
213 }
214 }
215 }
216 })
217 }
218 QRCodeStatus::Loading => Some(rsx! {
219 div { class: "adui-qrcode-cover",
220 div { class: "adui-qrcode-status",
221 div { class: "adui-qrcode-spinner" }
222 span { class: "adui-qrcode-status-text", "Loading..." }
223 }
224 }
225 }),
226 QRCodeStatus::Scanned => Some(rsx! {
227 div { class: "adui-qrcode-cover",
228 div { class: "adui-qrcode-status",
229 span { class: "adui-qrcode-status-icon", "✓" }
230 span { class: "adui-qrcode-status-text", "Scanned" }
231 }
232 }
233 }),
234 };
235
236 rsx! {
237 div {
238 class: "{root_class_attr}",
239 div {
240 class: "{class_attr}",
241 style: "{container_style}",
242 {qr_content}
243 {status_overlay}
244 }
245 }
246 }
247}
248
249fn generate_qr_modules(value: &str, error_level: QRCodeErrorLevel) -> Vec<Vec<bool>> {
252 let version = calculate_version(value.len(), error_level);
257 let module_count = 21 + (version - 1) * 4;
258
259 let mut modules = vec![vec![false; module_count]; module_count];
260
261 add_finder_pattern(&mut modules, 0, 0);
263 add_finder_pattern(&mut modules, module_count - 7, 0);
264 add_finder_pattern(&mut modules, 0, module_count - 7);
265
266 add_timing_patterns(&mut modules, module_count);
268
269 if version > 1 {
271 add_alignment_pattern(&mut modules, module_count);
272 }
273
274 fill_data_pattern(&mut modules, value, module_count);
276
277 modules
278}
279
280fn calculate_version(content_len: usize, _error_level: QRCodeErrorLevel) -> usize {
282 if content_len <= 17 {
284 1
285 } else if content_len <= 32 {
286 2
287 } else if content_len <= 53 {
288 3
289 } else if content_len <= 78 {
290 4
291 } else if content_len <= 106 {
292 5
293 } else {
294 6.min((content_len / 20).max(1))
295 }
296}
297
298fn add_finder_pattern(modules: &mut [Vec<bool>], row: usize, col: usize) {
300 for r in 0..7 {
301 for c in 0..7 {
302 let is_border = r == 0 || r == 6 || c == 0 || c == 6;
303 let is_inner = (2..=4).contains(&r) && (2..=4).contains(&c);
304 if row + r < modules.len() && col + c < modules[0].len() {
305 modules[row + r][col + c] = is_border || is_inner;
306 }
307 }
308 }
309
310 for i in 0..8 {
312 if row + 7 < modules.len() && col + i < modules[0].len() {
313 modules[row + 7][col + i] = false;
314 }
315 if row + i < modules.len() && col + 7 < modules[0].len() {
316 modules[row + i][col + 7] = false;
317 }
318 }
319}
320
321fn add_timing_patterns(modules: &mut [Vec<bool>], size: usize) {
323 for i in 8..size - 8 {
324 let pattern = i % 2 == 0;
325 modules[6][i] = pattern;
326 modules[i][6] = pattern;
327 }
328}
329
330fn add_alignment_pattern(modules: &mut [Vec<bool>], size: usize) {
332 let center = size - 7;
333 for r in 0..5 {
334 for c in 0..5 {
335 let is_border = r == 0 || r == 4 || c == 0 || c == 4;
336 let is_center = r == 2 && c == 2;
337 let row = center - 2 + r;
338 let col = center - 2 + c;
339 if row < size && col < size {
340 modules[row][col] = is_border || is_center;
341 }
342 }
343 }
344}
345
346fn fill_data_pattern(modules: &mut [Vec<bool>], value: &str, size: usize) {
348 let hash = simple_hash(value);
350 let mut seed = hash;
351
352 for row in 0..size {
353 for col in 0..size {
354 if is_function_module(row, col, size) {
356 continue;
357 }
358
359 seed = (seed.wrapping_mul(1103515245).wrapping_add(12345)) & 0x7fffffff;
361 modules[row][col] = (seed % 3) != 0;
362 }
363 }
364}
365
366fn is_function_module(row: usize, col: usize, size: usize) -> bool {
368 if row < 9 && col < 9 {
370 return true;
371 }
372 if row < 9 && col >= size - 8 {
374 return true;
375 }
376 if row >= size - 8 && col < 9 {
378 return true;
379 }
380 if row == 6 || col == 6 {
382 return true;
383 }
384 false
385}
386
387fn simple_hash(s: &str) -> u32 {
389 let mut hash: u32 = 5381;
390 for byte in s.bytes() {
391 hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
392 }
393 hash
394}
395
396fn render_svg_qrcode(
398 modules: &[Vec<bool>],
399 module_count: usize,
400 size: u32,
401 color: &str,
402 icon: &Option<String>,
403 icon_size: u32,
404) -> Element {
405 let module_size = size as f32 / module_count as f32;
406
407 let mut path_data = String::new();
409 for (row, row_modules) in modules.iter().enumerate() {
410 for (col, &is_dark) in row_modules.iter().enumerate() {
411 if is_dark {
412 let x = col as f32 * module_size;
413 let y = row as f32 * module_size;
414 path_data.push_str(&format!(
415 "M{:.2},{:.2}h{:.2}v{:.2}h-{:.2}z",
416 x, y, module_size, module_size, module_size
417 ));
418 }
419 }
420 }
421
422 let icon_element = icon.as_ref().map(|url| {
423 let icon_x = (size - icon_size) / 2;
424 let icon_y = (size - icon_size) / 2;
425 rsx! {
426 g {
427 rect {
428 x: "{icon_x}",
429 y: "{icon_y}",
430 width: "{icon_size}",
431 height: "{icon_size}",
432 fill: "white",
433 rx: "4",
434 }
435 image {
436 href: "{url}",
437 x: "{icon_x}",
438 y: "{icon_y}",
439 width: "{icon_size}",
440 height: "{icon_size}",
441 }
442 }
443 }
444 });
445
446 rsx! {
447 svg {
448 xmlns: "http://www.w3.org/2000/svg",
449 width: "{size}",
450 height: "{size}",
451 view_box: "0 0 {size} {size}",
452 shape_rendering: "crispEdges",
453 path {
454 fill: "{color}",
455 d: "{path_data}",
456 }
457 {icon_element}
458 }
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn status_class_mapping_is_stable() {
468 assert_eq!(QRCodeStatus::Active.as_class(), "adui-qrcode-active");
469 assert_eq!(QRCodeStatus::Expired.as_class(), "adui-qrcode-expired");
470 assert_eq!(QRCodeStatus::Loading.as_class(), "adui-qrcode-loading");
471 assert_eq!(QRCodeStatus::Scanned.as_class(), "adui-qrcode-scanned");
472 }
473
474 #[test]
475 fn calculate_version_increases_with_content_length() {
476 assert_eq!(calculate_version(10, QRCodeErrorLevel::M), 1);
477 assert_eq!(calculate_version(30, QRCodeErrorLevel::M), 2);
478 assert_eq!(calculate_version(50, QRCodeErrorLevel::M), 3);
479 }
480
481 #[test]
482 fn simple_hash_is_deterministic() {
483 let hash1 = simple_hash("test");
484 let hash2 = simple_hash("test");
485 let hash3 = simple_hash("different");
486
487 assert_eq!(hash1, hash2);
488 assert_ne!(hash1, hash3);
489 }
490
491 #[test]
492 fn generate_qr_modules_creates_correct_size() {
493 let modules = generate_qr_modules("test", QRCodeErrorLevel::M);
494 assert_eq!(modules.len(), 21);
496 assert_eq!(modules[0].len(), 21);
497 }
498
499 #[test]
500 fn finder_pattern_is_added_correctly() {
501 let mut modules = vec![vec![false; 21]; 21];
502 add_finder_pattern(&mut modules, 0, 0);
503
504 assert!(modules[0][0]); assert!(modules[0][6]); assert!(modules[6][0]); assert!(modules[6][6]); assert!(modules[3][3]);
512 }
513}