From 154ebcefff9eba3a05b1f3b449fce45f502e8678 Mon Sep 17 00:00:00 2001 From: Ilya Zverev Date: Sat, 25 May 2024 23:24:43 +0300 Subject: [PATCH] QR code scanner --- CHANGELOG.md | 10 +++--- android/gradle.properties | 1 + ios/Runner/Info.plist | 2 ++ lib/fields/helpers/qr_code.dart | 46 ++++++++++++++++++++++++++++ lib/fields/helpers/website_fmt.dart | 47 +++++++++++++++++++---------- lib/fields/website.dart | 42 +++++++++++++++++++++++++- lib/l10n/app_en.arb | 4 +++ pubspec.lock | 8 +++++ pubspec.yaml | 1 + 9 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 lib/fields/helpers/qr_code.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e434b425..d245d5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,14 @@ _Unreleased_ -* Added the recent walked path display in small blue dots. +* Added the recent walked path display as small blue dots. * GeoScribbles drawing is locked by default. +* QR code scanner for the website field. * When deleting an amenity with a unique address, suggest keeping the address. * Now possible to move nodes that are relation members. -* Fixed blue marker for unmovable objects in the editor. -* Minimum supported Android version is 5.0 now, due to Flutter upgrade. +* Fixed a blue marker for unmovable objects in the editor. +* Fixed the white map when adding an object on iOS. +* Minimal supported Android version is 5.0 now, due to the Flutter upgrade. ## 5.0 @@ -34,7 +36,7 @@ _Released on 2024-05-06_ * Speed fields are not filtered out now. * Removed the password login button. * Switched to Dart 3 and upgraded `flutter_map` to version 6. -* Minimum supported iOS version is 12 now. +* Minimal supported iOS version is 12 now. * Translations into Estonian (thanks August Murasev Frokjaer), Odia (thanks Soumendra Kumar Sahoo), and major updates to Croatian (thanks Milo Ivir). diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..2c8900a8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +dev.steenbakker.mobile_scanner.useUnbundled=true \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e4f6eb35..d300cdd7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -49,6 +49,8 @@ This app is for mapping amenities around you, so it needs to know where you are. NSLocationAlwaysUsageDescription The editor does not use location in the background. + NSCameraUsageDescription + This app needs camera access to scan QR codes. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/fields/helpers/qr_code.dart b/lib/fields/helpers/qr_code.dart new file mode 100644 index 00000000..256c6e98 --- /dev/null +++ b/lib/fields/helpers/qr_code.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class QrCodeScanner extends StatefulWidget { + const QrCodeScanner({super.key}); + + @override + State createState() => _QrCodeScannerState(); +} + +class _QrCodeScannerState extends State { + bool done = false; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar( + title: Text(loc.fieldWebsiteQR), + ), + body: MobileScanner( + onDetect: (codes) { + if (!done && mounted && codes.barcodes.isNotEmpty) { + final code = codes.barcodes.first; + String? url; + if (code.type == BarcodeType.url) { + url = code.url?.url; + } else if (code.type == BarcodeType.text || + code.type == BarcodeType.unknown) { + final value = code.displayValue; + if (value != null && value.startsWith("http")) { + url = value; + } + } + + if (url != null) { + done = true; // we need this because it scans twice sometimes + Navigator.pop(context, url); + } + } + }, + ), + ); + } +} diff --git a/lib/fields/helpers/website_fmt.dart b/lib/fields/helpers/website_fmt.dart index 92f8653b..2710e97b 100644 --- a/lib/fields/helpers/website_fmt.dart +++ b/lib/fields/helpers/website_fmt.dart @@ -37,7 +37,8 @@ abstract class WebsiteProvider { required this.label}); /// Whether user-entered value can be parsed. - bool isValid(String full); + /// Set url: true to make sure it's the proper provider for an URL. + bool isValid(String full, {bool url = false}); /// Converts user-entered value into a proper tag value. String format(String value); @@ -89,7 +90,7 @@ class UrlWebsiteProvider extends WebsiteProvider { static const kValidWebsiteUrlSchemes = ["http", "https"]; @override - bool isValid(String full) { + bool isValid(String full, {bool url = false}) { try { final uri = Uri.parse(full.replaceAll(' ', '').trim()); if (uri.hasScheme && !kValidWebsiteUrlSchemes.contains(uri.scheme)) { @@ -122,6 +123,7 @@ class UrlWebsiteProvider extends WebsiteProvider { class _ProviderHelper extends WebsiteProvider { final RegExp _regexp; + final RegExp? _regexpUrl; final String _format; _ProviderHelper({ @@ -130,14 +132,17 @@ class _ProviderHelper extends WebsiteProvider { required super.key, required super.label, required RegExp regexp, + RegExp? regexpUrl, String? format, }) : _regexp = regexp, + _regexpUrl = regexpUrl, _format = format ?? '%s', - super( - prefixes: prefixes ?? const []); + super(prefixes: prefixes ?? const []); @override - bool isValid(String full) => _regexp.hasMatch(full.trim()); + bool isValid(String full, {bool url = false}) => !url + ? _regexp.hasMatch(full.trim()) + : _regexpUrl?.hasMatch(full) ?? false; @override String? url(String value) { @@ -171,8 +176,10 @@ class FacebookProvider extends _ProviderHelper { label: 'Facebook', prefixes: ['fb', 'facebook', 'face'], key: 'contact:facebook', - regexp: RegExp(r'(?:facebook(?:\.com)?/)?([^/ ]+)/?$'), - format: 'https://www.facebook.com/%s', + regexp: RegExp( + r'(?:facebook(?:\.com)?/)?((?:groups/)?[^/? ]+)/?(?:\?.*)?$'), + regexpUrl: RegExp(r'facebook\.com/((?:groups/)?[^/? ]+)/?(?:\?.*)?$'), + format: 'https://www.facebook.com/%s/', ); } @@ -184,6 +191,7 @@ class InstagramProvider extends _ProviderHelper { prefixes: ['i', 'insta', 'instagram', 'инстаграм'], key: 'contact:instagram', regexp: RegExp(r'(?:instagram(?:\.com)?/)?([^/ ?]+)/?(\?[\w=]+)?$'), + regexpUrl: RegExp(r'instagram\.com/([^/ ?]+)/?(\?[\w=]+)?$'), format: 'https://www.instagram.com/%s', ); } @@ -195,7 +203,8 @@ class VkProvider extends _ProviderHelper { label: 'Vk', prefixes: ['vk', 'вк'], key: 'contact:vk', - regexp: RegExp(r'(?:vk(?:ontakte)?(?:\.com|\.ru)?/)?([^/ ]+)/?$'), + regexp: RegExp(r'(?:vk(?:ontakte)?(?:\.com|\.ru)?/)?([^/? ]+)/?$'), + regexpUrl: RegExp(r'vk(?:ontakte)?\.(?:com|ru)/([^/? ]+)/?$'), format: 'https://vk.com/%s', ); @@ -218,6 +227,7 @@ class TwitterProvider extends _ProviderHelper { prefixes: ['tw', 'twitter'], key: 'contact:twitter', regexp: RegExp(r'(?:twitter(?:\.com)?/)?([^/ ]+)/?$'), + regexpUrl: RegExp(r'(?:twitter|//x)\.com/([^/ ]+)/?$'), format: 'https://twitter.com/%s', ); } @@ -230,6 +240,7 @@ class OkProvider extends _ProviderHelper { prefixes: ['ok', 'ок', 'однокл', 'одноклассники'], key: 'contact:ok', regexp: RegExp(r'(?:ok\.ru/)?([^/ ]+)/?$'), + regexpUrl: RegExp(r'ok\.ru/([^/ ]+)/?$'), format: 'https://ok.ru/%s', ); } @@ -241,7 +252,8 @@ class TelegramProvider extends _ProviderHelper { label: 'Telegram', prefixes: ['tg', 'telegram'], key: 'contact:telegram', - regexp: RegExp(r'(?://t.me/|^t.me/)?([^/ ]+)/?$'), + regexp: RegExp(r'(?://t.me/|^t.me/)?([^/? ]+)/?$'), + regexpUrl: RegExp(r'//t.me/([^/? ]+)/?$'), format: 'https://t.me/%s', ); } @@ -271,19 +283,21 @@ class ViberProvider extends _ProviderHelper { key: 'contact:viber', regexp: RegExp( r'(?:chats\.viber\.com/|chatURI=)?(\+[\d -]+\d|[^/ ]+)/?$'), + regexpUrl: RegExp(r'chats\.viber\.com/(\+[\d -]+\d|[^/ ]+)/?$'), ); } class LinkedinProvider extends _ProviderHelper { LinkedinProvider() : super( - icon: LineIcons.linkedin, - label: 'LinkedIn', - prefixes: ['linkedin', 'li'], - key: 'contact:linkedin', - regexp: RegExp(r'(?:linkedin\.com/company/)?([^/ ]+)/?$'), - format: 'https://www.linkedin.com/company/%s', - ); + icon: LineIcons.linkedin, + label: 'LinkedIn', + prefixes: ['linkedin', 'li'], + key: 'contact:linkedin', + regexp: RegExp(r'(?:linkedin\.com/company/)?([^/? ]+)/?$'), + regexpUrl: RegExp(r'linkedin\.com/company/([^/? ]+)/?$'), + format: 'https://www.linkedin.com/company/%s', + ); } class TikTokProvider extends _ProviderHelper { @@ -294,6 +308,7 @@ class TikTokProvider extends _ProviderHelper { prefixes: ['tiktok', 'tt'], key: 'contact:tiktok', regexp: RegExp(r'(?:tiktok\.com/)?@?([^@ /?]+)'), + regexpUrl: RegExp(r'tiktok\.com/@([^@ /?]+)'), format: 'https://www.tiktok.com/@%s', ); } diff --git a/lib/fields/website.dart b/lib/fields/website.dart index cffb0e22..82e50934 100644 --- a/lib/fields/website.dart +++ b/lib/fields/website.dart @@ -1,8 +1,10 @@ import 'package:every_door/constants.dart'; +import 'package:every_door/fields/helpers/qr_code.dart'; import 'package:every_door/models/amenity.dart'; import 'package:every_door/providers/editor_settings.dart'; import 'package:flutter/material.dart'; import 'package:every_door/models/field.dart'; +import 'package:logging/logging.dart'; import 'package:url_launcher/url_launcher.dart'; import 'helpers/website_fmt.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -34,6 +36,8 @@ class WebsiteInputField extends ConsumerStatefulWidget { } class _WebsiteInputFieldState extends ConsumerState { + static final _logger = Logger('WebsiteInputField'); + late TextEditingController _controller; late WebsiteProvider _provider; late FocusNode _fieldFocus; @@ -76,6 +80,31 @@ class _WebsiteInputFieldState extends ConsumerState { return true; } + detectAndSubmitUrl(String url) { + WebsiteProvider? found; + // Skip first for the end (it's the generic URL provider). + for (int i = 1; i < websiteProviders.length; i++) { + if (websiteProviders[i].isValid(url, url: true)) { + found = websiteProviders[i]; + break; + } + } + if (found == null && websiteProviders[0].isValid(url, url: true)) { + found = websiteProviders[0]; + } + + _logger.info('Provider ${found?.label ?? "unknown"} for $url'); + if (found != null) { + setState(() { + // Weird we need to do this check twice. + if (found != null) { + found.setValue(widget.element, found.format(url), + preferContact: ref.read(editorSettingsProvider).preferContact); + } + }); + } + } + showProviderChooser() async { final result = await showModalBottomSheet( context: context, @@ -182,7 +211,18 @@ class _WebsiteInputFieldState extends ConsumerState { }, ), ), - ) + ), + IconButton( + icon: Icon( + Icons.qr_code_scanner, + size: 30.0, + ), + onPressed: () async { + final String? detected = await Navigator.push(context, + MaterialPageRoute(builder: (_) => QrCodeScanner())); + if (detected != null) detectAndSubmitUrl(detected); + }, + ), ], ), for (final website in websites) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c93c72b..0e416072 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -923,6 +923,10 @@ "@fieldHeightAny": { "description": "Validation message for when meters or feet are expected." }, + "fieldWebsiteQR": "Scan a QR Code", + "@fieldWebsiteQR": { + "description": "Page title for the QR code scanner." + }, "messageNoData": "No data, tap here to download", "@messageNoData": { "description": "Message that appears on the main screen when there's no OSM data." diff --git a/pubspec.lock b/pubspec.lock index 1e6cedb1..3fa4e517 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -694,6 +694,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: b8c0e9afcfd52534f85ec666f3d52156f560b5e6c25b1e3d4fe2087763607926 + url: "https://pub.dev" + source: hosted + version: "5.1.1" node_preamble: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0aea61df..18327c68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: latlong2: ^0.9.0 line_icons: ^2.0.1 logging: ^1.0.2 + mobile_scanner: ^5.1.1 oauth2_client: ^3.2.2 path: ^1.8.1 path_drawing: ^1.0.0