diff --git a/.metadata b/.metadata index 768732a..6d814d2 100644 --- a/.metadata +++ b/.metadata @@ -27,9 +27,6 @@ migration: - platform: macos create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - - platform: web - create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - platform: windows create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 diff --git a/README.md b/README.md index 46b2a3b..49746b6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Ichino 基于 QR Code 登录的 *某游戏* 客户端,Flutter 实现,覆盖 -Android / iOS / Web / Windows / macOS / Linux。 +Android / iOS / Windows / macOS / Linux。 包名 `ichino`,应用标识 `dev.naominet.ichino`。 @@ -23,7 +23,6 @@ Android / iOS / Web / Windows / macOS / Linux。 ```bash flutter pub get flutter run # 默认设备 -flutter run -d chrome # Web flutter analyze # 需保持 0 error/warning flutter test ``` @@ -35,7 +34,6 @@ flutter build apk --release # Android flutter build windows --release # Windows(产物为 ichino.exe) flutter build macos --release # macOS flutter build linux --release # Linux -flutter build web --release # Web flutter build ios --release --no-codesign # iOS(无证书时只验证可编译) ``` @@ -51,9 +49,9 @@ lib/ └── widgets/ # 可复用视觉组件(B50 海报) ``` -平台相关代码用条件导入分离,形如 `xxx_service.dart` + `xxx_service_native.dart` -+ `xxx_service_web.dart`(见 `qr_service`、`file_picker_service`、 -`png_export_service`、`music_data_cache`)。 +平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的 +`SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供, +桌面平台没有该 handler,因此从图片识别二维码仅在移动端可用。 ## 关键实现说明 diff --git a/analysis_options.yaml b/analysis_options.yaml index bf8d421..c29f376 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,7 +12,6 @@ analyzer: - build/** - android/** - ios/** - - web/** - windows/** - macos/** - linux/** diff --git a/lib/services/file_picker_service.dart b/lib/services/file_picker_service.dart index b13dad4..15cd66b 100644 --- a/lib/services/file_picker_service.dart +++ b/lib/services/file_picker_service.dart @@ -1,7 +1,11 @@ import 'dart:typed_data'; -import 'file_picker_service_native.dart' - if (dart.library.html) 'file_picker_service_web.dart' - as impl; +import 'package:image_picker/image_picker.dart'; -Future pickImageBytes() => impl.pickImageBytes(); +/// 从相册选取一张图片并读出字节,供 QR 解码用。 +Future pickImageBytes() async { + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.gallery); + if (file == null) return null; + return await file.readAsBytes(); +} diff --git a/lib/services/file_picker_service_native.dart b/lib/services/file_picker_service_native.dart deleted file mode 100644 index d3117db..0000000 --- a/lib/services/file_picker_service_native.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:typed_data'; - -import 'package:image_picker/image_picker.dart'; - -Future pickImageBytes() async { - final picker = ImagePicker(); - final file = await picker.pickImage(source: ImageSource.gallery); - if (file == null) return null; - return await file.readAsBytes(); -} diff --git a/lib/services/file_picker_service_web.dart b/lib/services/file_picker_service_web.dart deleted file mode 100644 index 8cb08f8..0000000 --- a/lib/services/file_picker_service_web.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:async'; -import 'dart:html'; -import 'dart:typed_data'; - -Future pickImageBytes() async { - final completer = Completer(); - - final input = FileUploadInputElement() - ..accept = 'image/*' - ..click(); - - // Detect cancellation: when user closes the file dialog without picking, - // onChange won't fire, but window.onFocus will when focus returns. - StreamSubscription? focusSub; - focusSub = window.onFocus.listen((_) { - focusSub?.cancel(); - if (!completer.isCompleted) { - completer.complete(null); - } - }); - - input.onChange.listen((_) { - focusSub?.cancel(); - - final file = input.files?.first; - if (file == null) { - completer.complete(null); - return; - } - - final reader = FileReader(); - reader.onLoad.listen((_) { - completer.complete(reader.result as Uint8List); - }); - - reader.onError.listen((_) { - completer.complete(null); - }); - - reader.readAsArrayBuffer(file); - }); - - return completer.future; -} diff --git a/lib/services/music_data_cache.dart b/lib/services/music_data_cache.dart index 95c0cea..d39055d 100644 --- a/lib/services/music_data_cache.dart +++ b/lib/services/music_data_cache.dart @@ -1,8 +1,31 @@ -import 'music_data_cache_native.dart' - if (dart.library.html) 'music_data_cache_web.dart' as impl; +import 'dart:io'; -/// 乐曲元数据的磁盘缓存,Web 平台为空实现。读失败返回 null,写失败静默忽略。 -Future readTextCache(String fileName) => impl.readTextCache(fileName); +/// 乐曲元数据的磁盘缓存。读失败返回 null,写失败静默忽略。 +/// +/// 乐曲元数据是可重新下载的参考数据,放系统临时目录即可,不引入 +/// path_provider(它经 path_provider_android 拖进 jni 插件,jni 会在 +/// Windows 上强行链接 jvm.lib)。 +Future readTextCache(String fileName) async { + try { + final file = await _file(fileName); + if (!await file.exists()) return null; + final content = await file.readAsString(); + return content.isEmpty ? null : content; + } catch (_) { + return null; + } +} -Future writeTextCache(String fileName, String content) => - impl.writeTextCache(fileName, content); +Future writeTextCache(String fileName, String content) async { + try { + await (await _file(fileName)).writeAsString(content); + } catch (_) { + // 缓存是尽力而为,失败只影响下次启动的加载速度。 + } +} + +Future _file(String fileName) async { + final dir = Directory('${Directory.systemTemp.path}/ichino_cache'); + await dir.create(recursive: true); + return File('${dir.path}/$fileName'); +} diff --git a/lib/services/music_data_cache_native.dart b/lib/services/music_data_cache_native.dart deleted file mode 100644 index 2f3a948..0000000 --- a/lib/services/music_data_cache_native.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:io'; - -/// 乐曲元数据是可重新下载的参考数据,放系统临时目录即可, -/// 不引入 path_provider(它经 path_provider_android 拖进 jni 插件, -/// jni 会在 Windows 上强行链接 jvm.lib)。 -Future readTextCache(String fileName) async { - try { - final file = await _file(fileName); - if (!await file.exists()) return null; - final content = await file.readAsString(); - return content.isEmpty ? null : content; - } catch (_) { - return null; - } -} - -Future writeTextCache(String fileName, String content) async { - try { - await (await _file(fileName)).writeAsString(content); - } catch (_) { - // 缓存是尽力而为,失败只影响下次启动的加载速度。 - } -} - -Future _file(String fileName) async { - final dir = Directory('${Directory.systemTemp.path}/ichino_cache'); - await dir.create(recursive: true); - return File('${dir.path}/$fileName'); -} diff --git a/lib/services/music_data_cache_web.dart b/lib/services/music_data_cache_web.dart deleted file mode 100644 index 0fe3dbe..0000000 --- a/lib/services/music_data_cache_web.dart +++ /dev/null @@ -1,3 +0,0 @@ -Future readTextCache(String fileName) async => null; - -Future writeTextCache(String fileName, String content) async {} diff --git a/lib/services/png_export_service.dart b/lib/services/png_export_service.dart index 66bc040..4dab2bc 100644 --- a/lib/services/png_export_service.dart +++ b/lib/services/png_export_service.dart @@ -1,10 +1,26 @@ +import 'dart:io'; import 'dart:typed_data'; -import 'png_export_service_native.dart' - if (dart.library.html) 'png_export_service_web.dart' as impl; +import 'package:share_plus/share_plus.dart'; -/// 导出一张 PNG:原生平台落临时盘并拉起系统分享,Web 走下载或 Web Share API。 +/// 导出一张 PNG:先落临时盘,再拉起系统分享面板。 /// -/// 返回原生平台上的落盘路径(用于分享能力缺失时提示用户),Web 返回 null。 -Future exportPng(Uint8List bytes, String fileName) => - impl.exportPng(bytes, fileName); +/// 返回落盘路径,供分享能力缺失时提示用户。Linux 与 Windows 10 RS5 之前 +/// 的 share_plus 不支持分享文件,会抛 UnimplementedError,此时文件仍在盘上。 +Future exportPng(Uint8List bytes, String fileName) async { + final file = File('${Directory.systemTemp.path}/$fileName'); + await file.writeAsBytes(bytes); + + try { + await SharePlus.instance.share( + ShareParams( + files: [XFile.fromData(bytes, name: fileName, mimeType: 'image/png')], + fileNameOverrides: [fileName], + ), + ); + } on UnimplementedError { + // 该平台不支持分享文件,文件已落盘,返回路径即可。 + } + + return file.path; +} diff --git a/lib/services/png_export_service_native.dart b/lib/services/png_export_service_native.dart deleted file mode 100644 index cc65b77..0000000 --- a/lib/services/png_export_service_native.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:share_plus/share_plus.dart'; - -Future exportPng(Uint8List bytes, String fileName) async { - final file = File('${Directory.systemTemp.path}/$fileName'); - await file.writeAsBytes(bytes); - - try { - await SharePlus.instance.share( - ShareParams( - files: [XFile.fromData(bytes, name: fileName, mimeType: 'image/png')], - fileNameOverrides: [fileName], - ), - ); - } on UnimplementedError { - // Linux 与 Windows 10 RS5 之前不支持分享文件,文件已落盘,返回路径即可。 - } - - return file.path; -} diff --git a/lib/services/png_export_service_web.dart b/lib/services/png_export_service_web.dart deleted file mode 100644 index 39a24d7..0000000 --- a/lib/services/png_export_service_web.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:typed_data'; - -import 'package:share_plus/share_plus.dart'; - -Future exportPng(Uint8List bytes, String fileName) async { - await SharePlus.instance.share( - ShareParams( - files: [ - XFile.fromData(bytes, name: fileName, mimeType: 'image/png'), - ], - fileNameOverrides: [fileName], - ), - ); - return null; -} diff --git a/lib/services/qr_service.dart b/lib/services/qr_service.dart index 914d2aa..accd5fe 100644 --- a/lib/services/qr_service.dart +++ b/lib/services/qr_service.dart @@ -1,8 +1,17 @@ -import 'dart:typed_data'; +import 'package:flutter/services.dart'; -import 'qr_service_native.dart' - if (dart.library.html) 'qr_service_web.dart' - as impl; +/// QR 解码走平台原生实现:Android 的 `MainActivity` 与 iOS 的 `SceneDelegate` +/// 注册了这个 MethodChannel。 +/// +/// 桌面平台(Windows/macOS/Linux)没有对应 handler,调用会抛 +/// `MissingPluginException`,由 `main.dart` 的登录页捕获后提示解析失败。 +const _channel = MethodChannel('dev.naominet.ichino/qr_scanner'); -Future decodeQRFromBytes(Uint8List bytes) => - impl.decodeQRFromBytes(bytes); +Future decodeQRFromBytes(Uint8List bytes) async { + try { + final result = await _channel.invokeMethod('decodeQRFromBytes', bytes); + return result; + } on PlatformException { + return null; + } +} diff --git a/lib/services/qr_service_native.dart b/lib/services/qr_service_native.dart deleted file mode 100644 index 2ebee8d..0000000 --- a/lib/services/qr_service_native.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/services.dart'; - -const _channel = MethodChannel('dev.naominet.ichino/qr_scanner'); - -Future decodeQRFromBytes(Uint8List bytes) async { - try { - final result = await _channel.invokeMethod('decodeQRFromBytes', bytes); - return result; - } on PlatformException { - return null; - } -} diff --git a/lib/services/qr_service_web.dart b/lib/services/qr_service_web.dart deleted file mode 100644 index 3cff4e2..0000000 --- a/lib/services/qr_service_web.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:async'; -import 'dart:html'; -import 'dart:js' as js; -import 'dart:typed_data'; - -Future decodeQRFromBytes(Uint8List bytes) async { - final completer = Completer(); - - final blob = Blob([bytes]); - final url = Url.createObjectUrlFromBlob(blob); - - final img = ImageElement()..src = url; - - img.onLoad.listen((_) { - try { - final canvas = CanvasElement( - width: img.naturalWidth, - height: img.naturalHeight, - ); - final ctx = canvas.context2D; - ctx.drawImage(img, 0, 0); - final imageData = ctx.getImageData( - 0, - 0, - img.naturalWidth, - img.naturalHeight, - ); - - if (!js.context.hasProperty('jsQR')) { - Url.revokeObjectUrl(url); - completer.completeError('jsQR library not loaded'); - return; - } - - final result = js.context.callMethod('jsQR', [ - imageData.data, - imageData.width, - imageData.height, - ]); - - Url.revokeObjectUrl(url); - - if (result != null) { - completer.complete(result['data'] as String?); - } else { - completer.complete(null); - } - } catch (e) { - Url.revokeObjectUrl(url); - completer.completeError(e); - } - }); - - img.onError.listen((_) { - Url.revokeObjectUrl(url); - completer.complete(null); - }); - - return completer.future; -} diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 8aaa46a..0000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfe..0000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48..0000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d7..0000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c566..0000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 20c7712..0000000 --- a/web/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - Ichino - - - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index 6d6b0a6..0000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Ichino", - "short_name": "Ichino", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -}