Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
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
literal 0
HcmV?d00001
diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8
GIT binary patch
literal 1418
zcmV;51$Fv~P)q
zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+
zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq
z^={4hPQv)y=I|4n+?>7Fim=dxt1
z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT
zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf`
zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_>
z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3
zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF
z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a
z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE
z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(
zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;?
zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-<
z{s<&cCV_1`^TD^ia9!*mQDq&
zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw
zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv
zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF
z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC
YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H
literal 0
HcmV?d00001
diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/example/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist
new file mode 100644
index 0000000..081750c
--- /dev/null
+++ b/example/ios/Runner/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Yx Only Office Flutter
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ yx_only_office_flutter_example
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/example/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..93d73f1
--- /dev/null
+++ b/example/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,27 @@
+import Flutter
+import UIKit
+import XCTest
+
+
+@testable import yx_only_office_flutter
+
+// This demonstrates a simple unit test of the Swift portion of this plugin's implementation.
+//
+// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+
+class RunnerTests: XCTestCase {
+
+ func testGetPlatformVersion() {
+ let plugin = YxOnlyOfficeFlutterPlugin()
+
+ let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: [])
+
+ let resultExpectation = expectation(description: "result block must be called.")
+ plugin.handle(call) { result in
+ XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion)
+ resultExpectation.fulfill()
+ }
+ waitForExpectations(timeout: 1)
+ }
+
+}
diff --git a/example/lib/main.dart b/example/lib/main.dart
new file mode 100644
index 0000000..099918b
--- /dev/null
+++ b/example/lib/main.dart
@@ -0,0 +1,321 @@
+import 'package:flutter/material.dart';
+import 'package:yx_only_office_flutter/yx_only_office_flutter.dart';
+
+const _serverUrl = String.fromEnvironment(
+ 'ONLYOFFICE_SERVER_URL',
+ defaultValue: '',
+);
+const _fileUrl = String.fromEnvironment(
+ 'ONLYOFFICE_FILE_URL',
+ defaultValue: '',
+);
+const _jwtSecret = String.fromEnvironment(
+ 'ONLYOFFICE_JWT_SECRET',
+ defaultValue: '',
+);
+
+void main() {
+ runApp(const OnlyOfficeDemoApp());
+}
+
+class OnlyOfficeDemoApp extends StatelessWidget {
+ const OnlyOfficeDemoApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'ONLYOFFICE Demo',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
+ home: const DemoHomePage(),
+ );
+ }
+}
+
+class DemoHomePage extends StatefulWidget {
+ const DemoHomePage({super.key});
+
+ @override
+ State createState() => _DemoHomePageState();
+}
+
+class _DemoHomePageState extends State {
+ String _mode = 'view'; // 'view' or 'edit'
+ bool _allowDownload = true;
+ bool _allowPrint = false;
+ bool _lockNavigation = true;
+ String? _lastError;
+ bool _hasUnsavedChanges = false;
+
+ @override
+ Widget build(BuildContext context) {
+ if (_serverUrl.isEmpty || _fileUrl.isEmpty) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('ONLYOFFICE Demo')),
+ body: const _MissingConfigHint(),
+ );
+ }
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('ONLYOFFICE Demo - ${_mode == 'edit' ? '编辑' : '查看'}模式'),
+ actions: [
+ if (_hasUnsavedChanges)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: Chip(
+ label: const Text('未保存', style: TextStyle(fontSize: 12)),
+ backgroundColor: Colors.orange.shade100,
+ avatar: const Icon(Icons.edit, size: 16),
+ ),
+ ),
+ IconButton(
+ tooltip: '刷新',
+ onPressed: () => setState(() {}),
+ icon: const Icon(Icons.refresh),
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ _buildControls(),
+ if (_lastError != null)
+ Material(
+ color: Theme.of(context).colorScheme.errorContainer,
+ child: ListTile(
+ leading: const Icon(Icons.error_outline),
+ title: Text(
+ _lastError!,
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onErrorContainer,
+ ),
+ ),
+ trailing: IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => setState(() => _lastError = null),
+ ),
+ ),
+ ),
+ Expanded(
+ child: YxOnlyOfficeViewer.create(
+ serverUrl: _serverUrl,
+ fileUrl: _fileUrl,
+ mode: _mode,
+ allowDownload: _allowDownload,
+ allowPrint: _allowPrint,
+ user: const OnlyOfficeUser(
+ id: 'demo-user-001',
+ name: '演示用户',
+ email: 'demo@example.com',
+ ),
+ customization: const OnlyOfficeCustomization(
+ compactToolbar: true,
+ ),
+ tokenFactory: _jwtSecret.isNotEmpty ? const OnlyOfficeJwtSigner(_jwtSecret) : null,
+ restrictNavigationToInitialPage: _lockNavigation,
+ loadingBuilder: (_) => const ColoredBox(
+ color: Colors.white,
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ onError: _handleError,
+ onAppClose: () => _showSnackBar('用户请求关闭编辑器'),
+ onDownloadAs: (type, url) => _showSnackBar('下载完成: $type -> $url'),
+ onRequestSaveAs: (data) {
+ _showSnackBar('用户请求另存为: $data');
+ debugPrint('onRequestSaveAs: $data');
+ },
+ onRequestInsertImage: (data) {
+ _showSnackBar('用户请求插入图片');
+ debugPrint('onRequestInsertImage: $data');
+ // 这里可以打开 Flutter 图片选择器
+ },
+ onDocumentStateChange: (data) {
+ final isModified = data == true;
+ setState(() => _hasUnsavedChanges = isModified);
+ debugPrint('文档状态变化: ${isModified ? "已修改" : "未修改"}');
+ },
+ onMetaChange: (data) {
+ debugPrint('文档元数据变化: $data');
+ },
+ onEvent: (event, data) {
+ // 通用事件处理器
+ debugPrint('📡 事件: $event, 数据: $data');
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildControls() {
+ return Card(
+ margin: const EdgeInsets.all(8),
+ child: Column(
+ children: [
+ ListTile(
+ title: const Text('文档模式'),
+ trailing: SegmentedButton(
+ segments: const [
+ ButtonSegment(value: 'view', label: Text('查看'), icon: Icon(Icons.visibility)),
+ ButtonSegment(value: 'edit', label: Text('编辑'), icon: Icon(Icons.edit)),
+ ],
+ selected: {_mode},
+ onSelectionChanged: (Set newSelection) {
+ setState(() {
+ _mode = newSelection.first;
+ _hasUnsavedChanges = false;
+ });
+ },
+ ),
+ ),
+ const Divider(height: 1),
+ SwitchListTile(
+ title: const Text('允许下载'),
+ subtitle: Text(_mode == 'edit' ? '编辑模式下建议开启' : '控制下载按钮显示'),
+ value: _allowDownload,
+ onChanged: (value) => setState(() => _allowDownload = value),
+ ),
+ SwitchListTile.adaptive(
+ title: const Text('允许打印'),
+ subtitle: const Text('控制打印功能'),
+ value: _allowPrint,
+ onChanged: (value) => setState(() => _allowPrint = value),
+ ),
+ SwitchListTile.adaptive(
+ title: const Text('限制导航'),
+ subtitle: const Text('防止 WebView 跳转到其他页面'),
+ value: _lockNavigation,
+ onChanged: (value) => setState(() => _lockNavigation = value),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _handleError(String message) {
+ setState(() => _lastError = message);
+ _showSnackBar('错误: $message');
+ }
+
+ void _showSnackBar(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(message),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+}
+
+class _MissingConfigHint extends StatelessWidget {
+ const _MissingConfigHint();
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '⚠️ 尚未配置 Document Server',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ '运行示例前请通过 --dart-define 传入以下环境变量:',
+ style: TextStyle(fontSize: 14),
+ ),
+ const SizedBox(height: 12),
+ _buildConfigItem('ONLYOFFICE_SERVER_URL', 'ONLYOFFICE 服务器地址', required: true),
+ _buildConfigItem('ONLYOFFICE_FILE_URL', '文档文件地址', required: true),
+ _buildConfigItem('ONLYOFFICE_JWT_SECRET', 'JWT 签名密钥(可选)', required: false),
+ const SizedBox(height: 20),
+ const Text(
+ '示例命令:',
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.grey.shade300),
+ ),
+ child: const SelectableText(
+ 'flutter run \\\n'
+ ' --dart-define ONLYOFFICE_SERVER_URL=https://doc.example.com \\\n'
+ ' --dart-define ONLYOFFICE_FILE_URL=https://doc.example.com/demo.docx \\\n'
+ ' --dart-define ONLYOFFICE_JWT_SECRET=your-secret-key',
+ style: TextStyle(fontFamily: 'monospace', fontSize: 12),
+ ),
+ ),
+ const SizedBox(height: 20),
+ const Divider(),
+ const SizedBox(height: 12),
+ const Text(
+ '📚 参考文档:',
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ _buildLink('ONLYOFFICE Docs API', 'https://api.onlyoffice.com/docs/docs-api/'),
+ _buildLink('Android 官方项目', 'https://github.com/ONLYOFFICE/documents-app-android'),
+ _buildLink('iOS 官方项目', 'https://github.com/ONLYOFFICE/documents-app-ios'),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildConfigItem(String key, String description, {required bool required}) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(
+ required ? Icons.check_circle : Icons.info_outline,
+ size: 16,
+ color: required ? Colors.red : Colors.blue,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ key,
+ style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace'),
+ ),
+ Text(
+ description,
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildLink(String title, String url) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Row(
+ children: [
+ const Icon(Icons.link, size: 14),
+ const SizedBox(width: 4),
+ Expanded(
+ child: SelectableText(
+ '$title: $url',
+ style: const TextStyle(fontSize: 12, color: Colors.blue),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/example/pubspec.lock b/example/pubspec.lock
new file mode 100644
index 0000000..f18811b
--- /dev/null
+++ b/example/pubspec.lock
@@ -0,0 +1,379 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ adaptive_number:
+ dependency: transitive
+ description:
+ name: adaptive_number
+ sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.0.0"
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.13.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.4.0"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.19.1"
+ convert:
+ dependency: transitive
+ description:
+ name: convert
+ sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.1.2"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.0.7"
+ dart_jsonwebtoken:
+ dependency: "direct dev"
+ description:
+ name: dart_jsonwebtoken
+ sha256: "6703695f581fc54d0a7e5f281c5538735167605bb9e5abd208c8b330625a92b1"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.12.1"
+ ed25519_edwards:
+ dependency: transitive
+ description:
+ name: ed25519_edwards
+ sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.3.1"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.3.3"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "7.0.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.1.1"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_driver:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.0.2"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ fuchsia_remote_debug_protocol:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ integration_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.7.2"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.0.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.12.17"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.11.1"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.16.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.9.1"
+ platform:
+ dependency: transitive
+ description:
+ name: platform
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.1.6"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.1.8"
+ pointycastle:
+ dependency: transitive
+ description:
+ name: pointycastle
+ sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.9.1"
+ process:
+ dependency: transitive
+ description:
+ name: process
+ sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "5.0.5"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.10.1"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.4.1"
+ sync_http:
+ dependency: transitive
+ description:
+ name: sync_http
+ sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.3.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "0.7.6"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "1.4.0"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "15.0.2"
+ webdriver:
+ dependency: transitive
+ description:
+ name: webdriver
+ sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.1.0"
+ webview_flutter:
+ dependency: transitive
+ description:
+ name: webview_flutter
+ sha256: ec81f57aa1611f8ebecf1d2259da4ef052281cb5ad624131c93546c79ccc7736
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "4.9.0"
+ webview_flutter_android:
+ dependency: transitive
+ description:
+ name: webview_flutter_android
+ sha256: "47a8da40d02befda5b151a26dba71f47df471cddd91dfdb7802d0a87c5442558"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.16.9"
+ webview_flutter_platform_interface:
+ dependency: transitive
+ description:
+ name: webview_flutter_platform_interface
+ sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "2.14.0"
+ webview_flutter_wkwebview:
+ dependency: transitive
+ description:
+ name: webview_flutter_wkwebview
+ sha256: a57b76a081bed3bf3a71a486bdf83642b00f1a7342043d50367cea68f338b1af
+ url: "https://pub.flutter-io.cn"
+ source: hosted
+ version: "3.23.4"
+ yx_only_office_flutter:
+ dependency: "direct main"
+ description:
+ path: ".."
+ relative: true
+ source: path
+ version: "0.1.0"
+sdks:
+ dart: ">=3.9.2 <4.0.0"
+ flutter: ">=3.35.0"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
new file mode 100644
index 0000000..7bcfff4
--- /dev/null
+++ b/example/pubspec.yaml
@@ -0,0 +1,23 @@
+name: yx_only_office_flutter_example
+description: Demonstrates how to use the yx_only_office_flutter plugin.
+publish_to: 'none'
+
+environment:
+ sdk: '>=3.3.5 <4.0.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ yx_only_office_flutter:
+ path: ../
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ integration_test:
+ sdk: flutter
+ flutter_lints: ^3.0.0
+ dart_jsonwebtoken: 2.12.1
+
+flutter:
+ uses-material-design: true
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
new file mode 100644
index 0000000..7f386f5
--- /dev/null
+++ b/example/test/widget_test.dart
@@ -0,0 +1,20 @@
+// This is a basic Flutter widget test.
+//
+// To perform an interaction with a widget in your test, use the WidgetTester
+// utility in the flutter_test package. For example, you can send tap and scroll
+// gestures. You can also use WidgetTester to find child widgets in the widget
+// tree, read text, and verify that the values of widget properties are correct.
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:yx_only_office_flutter_example/main.dart';
+
+void main() {
+ testWidgets('Verify app starts', (WidgetTester tester) async {
+ // Build our app and trigger a frame.
+ await tester.pumpWidget(const OnlyOfficeDemoApp());
+
+ // Verify that the app title is present (although it might show the config error screen initially)
+ expect(find.text('ONLYOFFICE Demo'), findsOneWidget);
+ });
+}
diff --git a/example/test_driver/integration_test.dart b/example/test_driver/integration_test.dart
new file mode 100644
index 0000000..b38629c
--- /dev/null
+++ b/example/test_driver/integration_test.dart
@@ -0,0 +1,3 @@
+import 'package:integration_test/integration_test_driver.dart';
+
+Future main() => integrationDriver();
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..034771f
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,38 @@
+.idea/
+.vagrant/
+.sconsign.dblite
+.svn/
+
+.DS_Store
+*.swp
+profile
+
+DerivedData/
+build/
+GeneratedPluginRegistrant.h
+GeneratedPluginRegistrant.m
+
+.generated/
+
+*.pbxuser
+*.mode1v3
+*.mode2v3
+*.perspectivev3
+
+!default.pbxuser
+!default.mode1v3
+!default.mode2v3
+!default.perspectivev3
+
+xcuserdata
+
+*.moved-aside
+
+*.pyc
+*sync/
+Icon?
+.tags*
+
+/Flutter/Generated.xcconfig
+/Flutter/ephemeral/
+/Flutter/flutter_export_environment.sh
diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/ios/Classes/YxOnlyOfficeFlutterPlugin.swift b/ios/Classes/YxOnlyOfficeFlutterPlugin.swift
new file mode 100644
index 0000000..d9c383b
--- /dev/null
+++ b/ios/Classes/YxOnlyOfficeFlutterPlugin.swift
@@ -0,0 +1,8 @@
+import Flutter
+import UIKit
+
+public class YxOnlyOfficeFlutterPlugin: NSObject, FlutterPlugin {
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ // Companion object is not available in Swift.
+ }
+}
diff --git a/ios/Resources/PrivacyInfo.xcprivacy b/ios/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..a34b7e2
--- /dev/null
+++ b/ios/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,14 @@
+
+
+
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyAccessedAPITypes
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyTracking
+
+
+
diff --git a/ios/yx_only_office_flutter.podspec b/ios/yx_only_office_flutter.podspec
new file mode 100644
index 0000000..ca64da5
--- /dev/null
+++ b/ios/yx_only_office_flutter.podspec
@@ -0,0 +1,29 @@
+#
+# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
+# Run `pod lib lint yx_only_office_flutter.podspec` to validate before publishing.
+#
+Pod::Spec.new do |s|
+ s.name = 'yx_only_office_flutter'
+ s.version = '0.0.1'
+ s.summary = 'A new Flutter plugin project.'
+ s.description = <<-DESC
+A new Flutter plugin project.
+ DESC
+ s.homepage = 'http://example.com'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Your Company' => 'email@example.com' }
+ s.source = { :path => '.' }
+ s.source_files = 'Classes/**/*'
+ s.dependency 'Flutter'
+ s.platform = :ios, '13.0'
+
+ # Flutter.framework does not contain a i386 slice.
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
+ s.swift_version = '5.0'
+
+ # If your plugin requires a privacy manifest, for example if it uses any
+ # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your
+ # plugin's privacy impact, and then uncomment this line. For more information,
+ # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
+ # s.resource_bundles = {'yx_only_office_flutter_privacy' => ['Resources/PrivacyInfo.xcprivacy']}
+end
diff --git a/lib/src/onlyoffice_config.dart b/lib/src/onlyoffice_config.dart
new file mode 100644
index 0000000..b537287
--- /dev/null
+++ b/lib/src/onlyoffice_config.dart
@@ -0,0 +1,421 @@
+import 'dart:convert';
+
+import 'package:crypto/crypto.dart';
+
+/// Immutable configuration object describing an ONLYOFFICE editor session.
+class OnlyOfficeConfig {
+ /// Standard document metadata.
+ final OnlyOfficeDocument document;
+
+ /// DocsAPI document type: `word`, `cell`, `slide`, `pdf`, or `diagram`.
+ final String documentType;
+
+ /// Editor settings such as mode, language, callbacks, etc.
+ final OnlyOfficeEditorConfig editorConfig;
+
+ /// Client type reported to DocsAPI. Typically `desktop`, `mobile`, or `embedded`.
+ final String type;
+
+ /// JWT token signed *outside* the plugin.
+ final String? token;
+
+ /// Arbitrary top-level configuration extensions passed through untouched.
+ final Map extra;
+
+ const OnlyOfficeConfig({
+ required this.document,
+ required this.documentType,
+ required this.editorConfig,
+ this.type = 'mobile',
+ this.token,
+ Map? extra,
+ }) : assert(
+ documentType == 'word' || documentType == 'cell' || documentType == 'slide' ||
+ documentType == 'pdf' || documentType == 'diagram',
+ 'documentType must be one of "word", "cell", "slide", "pdf", or "diagram" (v6.1+)',
+ ),
+ extra = extra ?? const {};
+
+ OnlyOfficeConfig copyWith({
+ OnlyOfficeDocument? document,
+ String? documentType,
+ OnlyOfficeEditorConfig? editorConfig,
+ String? type,
+ String? token,
+ Map? extra,
+ }) {
+ return OnlyOfficeConfig(
+ document: document ?? this.document,
+ documentType: documentType ?? this.documentType,
+ editorConfig: editorConfig ?? this.editorConfig,
+ type: type ?? this.type,
+ token: token ?? this.token,
+ extra: extra ?? this.extra,
+ );
+ }
+
+ Map toJson() {
+ final map = {
+ 'document': document.toJson(),
+ 'documentType': documentType,
+ 'editorConfig': editorConfig.toJson(),
+ 'type': type,
+ ...extra,
+ };
+ if (token != null && token!.isNotEmpty) {
+ map['token'] = token;
+ }
+ return map;
+ }
+
+ String toJsonString() => jsonEncode(toJson());
+
+ Map get rawConfig => toJson();
+}
+
+/// Basic document metadata required by ONLYOFFICE plus optional permissions/info.
+class OnlyOfficeDocument {
+ final String fileType;
+ final String key;
+ final String title;
+ final String url;
+ final OnlyOfficePermissions? permissions;
+ final Map info;
+ final Map extra;
+
+ const OnlyOfficeDocument({
+ required this.fileType,
+ required this.key,
+ required this.title,
+ required this.url,
+ this.permissions,
+ Map? info,
+ Map? extra,
+ }) : info = info ?? const {},
+ extra = extra ?? const {};
+
+ /// Generates a stable and unique document key from the document URL.
+ /// This is useful for ensuring that the same document is consistently identified by OnlyOffice.
+ static String generateKeyFromUrl(String url) {
+ return sha256.convert(utf8.encode(url)).toString();
+ }
+
+ OnlyOfficeDocument copyWith({
+ String? fileType,
+ String? key,
+ String? title,
+ String? url,
+ OnlyOfficePermissions? permissions,
+ Map? info,
+ Map? extra,
+ }) {
+ return OnlyOfficeDocument(
+ fileType: fileType ?? this.fileType,
+ key: key ?? this.key,
+ title: title ?? this.title,
+ url: url ?? this.url,
+ permissions: permissions ?? this.permissions,
+ info: info ?? this.info,
+ extra: extra ?? this.extra,
+ );
+ }
+
+ Map toJson() {
+ final map = {'fileType': fileType, 'key': key, 'title': title, 'url': url};
+ if (permissions != null) {
+ map['permissions'] = permissions!.toJson();
+ }
+ if (info.isNotEmpty) {
+ map['info'] = info;
+ }
+ map.addAll(extra);
+ return map;
+ }
+}
+
+/// Editor behavior/settings that we control on the client.
+class OnlyOfficeEditorConfig {
+ final String mode; // edit / view
+ final String lang;
+ final String? region;
+ final String? callbackUrl;
+ final OnlyOfficeUser? user;
+ final OnlyOfficePermissions? permissions;
+ final OnlyOfficeCustomization? customization;
+ final Map extra;
+
+ const OnlyOfficeEditorConfig({
+ this.mode = 'view',
+ this.lang = 'zh-CN',
+ this.region,
+ this.callbackUrl,
+ this.user,
+ this.permissions,
+ this.customization,
+ Map? extra,
+ }) : extra = extra ?? const {};
+
+ OnlyOfficeEditorConfig copyWith({
+ String? mode,
+ String? lang,
+ String? region,
+ String? callbackUrl,
+ OnlyOfficeUser? user,
+ OnlyOfficePermissions? permissions,
+ OnlyOfficeCustomization? customization,
+ Map? extra,
+ }) {
+ return OnlyOfficeEditorConfig(
+ mode: mode ?? this.mode,
+ lang: lang ?? this.lang,
+ region: region ?? this.region,
+ callbackUrl: callbackUrl ?? this.callbackUrl,
+ user: user ?? this.user,
+ permissions: permissions ?? this.permissions,
+ customization: customization ?? this.customization,
+ extra: extra ?? this.extra,
+ );
+ }
+
+ Map toJson() {
+ final map = {
+ 'mode': mode,
+ 'lang': lang,
+ if (region != null) 'region': region,
+ if (callbackUrl != null) 'callbackUrl': callbackUrl,
+ if (user != null) 'user': user!.toJson(),
+ if (permissions != null) 'permissions': permissions!.toJson(),
+ if (customization != null) 'customization': customization!.toJson(),
+ };
+ map.addAll(extra);
+ return map;
+ }
+}
+
+/// Fine-grained permissions used in multiple sections of the ONLYOFFICE config.
+class OnlyOfficePermissions {
+ final bool? edit;
+ final bool? download;
+ final bool? print;
+ final bool? review;
+ final bool? comment;
+ final bool? copy;
+ final bool? fillForms;
+ final Map extra;
+
+ const OnlyOfficePermissions({
+ this.edit,
+ this.download,
+ this.print,
+ this.review,
+ this.comment,
+ this.copy,
+ this.fillForms,
+ Map? extra,
+ }) : extra = extra ?? const {};
+
+ Map toJson() {
+ final map = {
+ if (edit != null) 'edit': edit,
+ if (download != null) 'download': download,
+ if (print != null) 'print': print,
+ if (review != null) 'review': review,
+ if (comment != null) 'comment': comment,
+ if (copy != null) 'copy': copy,
+ if (fillForms != null) 'fillForms': fillForms,
+ };
+ map.addAll(extra);
+ return map;
+ }
+}
+
+/// Metadata describing the current ONLYOFFICE user.
+class OnlyOfficeUser {
+ final String id;
+ final String name;
+ final String? group;
+ final String? email;
+ final Map extra;
+
+ const OnlyOfficeUser({required this.id, required this.name, this.group, this.email, Map? extra})
+ : extra = extra ?? const {};
+
+ Map toJson() {
+ final map = {
+ 'id': id,
+ 'name': name,
+ if (group != null) 'group': group,
+ if (email != null) 'email': email,
+ };
+ map.addAll(extra);
+ return map;
+ }
+}
+
+/// UI customization knobs supported by DocsAPI.
+class OnlyOfficeCustomization {
+ final bool? hideRightMenu;
+ final bool? hideLeftMenu;
+ final bool? hideRulers;
+ final bool? compactToolbar;
+ final bool? toolbarNoTabs;
+ final bool? showReviewChanges;
+ final Map extra;
+
+ const OnlyOfficeCustomization({
+ this.hideRightMenu,
+ this.hideLeftMenu,
+ this.hideRulers,
+ this.compactToolbar,
+ this.toolbarNoTabs,
+ this.showReviewChanges,
+ Map? extra,
+ }) : extra = extra ?? const {};
+
+ Map toJson() {
+ final map = {
+ if (hideRightMenu != null) 'hideRightMenu': hideRightMenu,
+ if (hideLeftMenu != null) 'hideLeftMenu': hideLeftMenu,
+ if (hideRulers != null) 'hideRulers': hideRulers,
+ if (compactToolbar != null) 'compactToolbar': compactToolbar,
+ if (toolbarNoTabs != null) 'toolbarNoTabs': toolbarNoTabs,
+ if (showReviewChanges != null) 'showReviewChanges': showReviewChanges,
+ };
+ map.addAll(extra);
+ return map;
+ }
+}
+
+/// Utility class to sign JWT tokens for ONLYOFFICE.
+class OnlyOfficeJwtSigner {
+ final String secret;
+
+ const OnlyOfficeJwtSigner(this.secret);
+
+ /// Signs the payload using HMAC SHA256 with the provided secret.
+ String sign(Map payload) {
+ // Create Header
+ final header = {'alg': 'HS256', 'typ': 'JWT'};
+ final headerBase64 = _base64UrlEncode(utf8.encode(jsonEncode(header)));
+
+ // Create Body
+ final bodyBase64 = _base64UrlEncode(utf8.encode(jsonEncode(payload)));
+
+ // Create Signature
+ final dataToSign = '$headerBase64.$bodyBase64';
+ final hmac = Hmac(sha256, utf8.encode(secret));
+ final digest = hmac.convert(utf8.encode(dataToSign));
+ final signatureBase64 = _base64UrlEncode(digest.bytes);
+
+ return '$dataToSign.$signatureBase64';
+ }
+
+ String _base64UrlEncode(List bytes) {
+ return base64Url.encode(bytes).replaceAll('=', '');
+ }
+}
+
+/// Factory to create common configurations easily.
+class OnlyOfficeConfigFactory {
+ const OnlyOfficeConfigFactory._();
+
+ static OnlyOfficeConfig create({
+ required String fileUrl,
+ String mode = 'view', // 'view' or 'edit'
+ String? title,
+ bool allowDownload = true,
+ bool allowPrint = false,
+ OnlyOfficeUser? user,
+ OnlyOfficeCustomization? customization,
+ OnlyOfficeJwtSigner? tokenFactory,
+ Map? extra,
+ String lang = 'zh-CN',
+ String? key,
+ }) {
+ final fileName = title ?? Uri.parse(fileUrl).pathSegments.last;
+ final extension = fileName.contains('.') ? fileName.split('.').last.toLowerCase() : 'docx';
+ final documentType = _getDocumentType(extension);
+
+ final isEdit = mode == 'edit';
+
+ final document = OnlyOfficeDocument(
+ fileType: extension,
+ key: key ?? OnlyOfficeDocument.generateKeyFromUrl(fileUrl),
+ title: fileName,
+ url: fileUrl,
+ permissions: OnlyOfficePermissions(
+ download: allowDownload,
+ print: allowPrint,
+ edit: isEdit,
+ review: isEdit,
+ comment: isEdit,
+ ),
+ );
+
+ final editorConfig = OnlyOfficeEditorConfig(
+ mode: mode,
+ lang: lang,
+ user: user,
+ customization: customization,
+ );
+
+ var config = OnlyOfficeConfig(
+ document: document,
+ documentType: documentType,
+ editorConfig: editorConfig,
+ extra: extra,
+ );
+
+ if (tokenFactory != null) {
+ final token = tokenFactory.sign(config.toJson());
+ config = config.copyWith(token: token);
+ }
+
+ return config;
+ }
+
+ static String _getDocumentType(String ext) {
+ // Based on OnlyOffice API docs
+ const word = {
+ 'doc', 'docx', 'docm', 'dot', 'dotx', 'dotm', 'odt', 'fodt', 'ott', 'rtf', 'txt',
+ 'html', 'htm', 'mht', 'xml', 'pdf', 'djvu', 'fb2', 'epub', 'xps'
+ };
+ const cell = {'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'xltm', 'ods', 'fods', 'ots', 'csv'};
+ const slide = {'ppt', 'pptx', 'pptm', 'pps', 'ppsx', 'ppsm', 'pot', 'potx', 'potm', 'odp', 'fodp', 'otp'};
+
+ // Additional types supported by newer versions can be added here
+
+ if (word.contains(ext)) return 'word';
+ if (cell.contains(ext)) return 'cell';
+ if (slide.contains(ext)) return 'slide';
+ return 'word'; // Default fallback
+ }
+}
+
+@Deprecated('Use OnlyOfficeConfigFactory instead')
+class OnlyOfficeViewConfigFactory {
+ const OnlyOfficeViewConfigFactory._();
+
+ static OnlyOfficeConfig fromUrl({
+ required String fileUrl,
+ String? title,
+ bool allowDownload = true,
+ bool allowPrint = false,
+ OnlyOfficeUser? user,
+ OnlyOfficeCustomization? customization,
+ OnlyOfficeJwtSigner? tokenFactory,
+ Map? extra,
+ }) {
+ return OnlyOfficeConfigFactory.create(
+ fileUrl: fileUrl,
+ title: title,
+ allowDownload: allowDownload,
+ allowPrint: allowPrint,
+ user: user,
+ customization: customization,
+ tokenFactory: tokenFactory,
+ extra: extra,
+ mode: 'view',
+ );
+ }
+}
diff --git a/lib/src/onlyoffice_html_builder.dart b/lib/src/onlyoffice_html_builder.dart
new file mode 100644
index 0000000..180b82e
--- /dev/null
+++ b/lib/src/onlyoffice_html_builder.dart
@@ -0,0 +1,117 @@
+import 'onlyoffice_config.dart';
+
+/// Builds the HTML string that bootstraps the ONLYOFFICE web editor.
+class OnlyOfficeHtmlBuilder {
+ const OnlyOfficeHtmlBuilder._();
+
+ /// Generates the HTML document that loads the DocsAPI and initializes the editor.
+ static String build({required String serverUrl, required OnlyOfficeConfig config}) {
+ final normalizedServerUrl = normalizeServerUrl(serverUrl);
+ final configJson = config.toJsonString();
+
+ return '''
+
+
+
+
+
+ OnlyOffice Viewer
+
+
+
+
+
+
+
+
+''';
+ }
+
+ /// Normalizes the server URL by trimming whitespace and removing a trailing slash.
+ static String normalizeServerUrl(String serverUrl) {
+ final trimmed = serverUrl.trim();
+ if (trimmed.isEmpty) {
+ return trimmed;
+ }
+ return trimmed.endsWith('/') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
+ }
+}
diff --git a/lib/src/onlyoffice_viewer.dart b/lib/src/onlyoffice_viewer.dart
new file mode 100644
index 0000000..29a3df1
--- /dev/null
+++ b/lib/src/onlyoffice_viewer.dart
@@ -0,0 +1,354 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:webview_flutter/webview_flutter.dart';
+import 'package:webview_flutter_android/webview_flutter_android.dart';
+import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
+
+import 'onlyoffice_config.dart';
+import 'onlyoffice_html_builder.dart';
+
+class YxOnlyOfficeViewer extends StatefulWidget {
+ final String serverUrl;
+ final OnlyOfficeConfig config;
+
+ final Function(String)? onError;
+ final Function()? onAppClose;
+ final Function(String, String)? onDownloadAs;
+ final Function(dynamic)? onRequestSaveAs;
+ final Function(dynamic)? onRequestInsertImage;
+ final Function(dynamic)? onDocumentStateChange;
+ final Function(dynamic)? onMetaChange;
+ final Function(dynamic)? onMakeActionLink;
+ final Function(String event, dynamic data)? onEvent;
+
+ final WidgetBuilder? loadingBuilder;
+ final Widget Function(BuildContext context, Object error)? errorBuilder;
+ final bool restrictNavigationToInitialPage;
+
+ const YxOnlyOfficeViewer({
+ super.key,
+ required this.serverUrl,
+ required this.config,
+ this.onError,
+ this.onAppClose,
+ this.onDownloadAs,
+ this.onRequestSaveAs,
+ this.onRequestInsertImage,
+ this.onDocumentStateChange,
+ this.onMetaChange,
+ this.onMakeActionLink,
+ this.onEvent,
+ this.loadingBuilder,
+ this.errorBuilder,
+ this.restrictNavigationToInitialPage = true,
+ });
+
+ /// Convenience constructor for viewing or editing a document from a URL.
+ factory YxOnlyOfficeViewer.create({
+ Key? key,
+ required String serverUrl,
+ required String fileUrl,
+ String mode = 'view',
+ String? title,
+ bool allowDownload = true,
+ bool allowPrint = false,
+ OnlyOfficeUser? user,
+ OnlyOfficeCustomization? customization,
+ OnlyOfficeJwtSigner? tokenFactory,
+ Map? extra,
+ Function(String)? onError,
+ Function()? onAppClose,
+ Function(String, String)? onDownloadAs,
+ Function(dynamic)? onRequestSaveAs,
+ Function(dynamic)? onRequestInsertImage,
+ Function(dynamic)? onDocumentStateChange,
+ Function(dynamic)? onMetaChange,
+ Function(dynamic)? onMakeActionLink,
+ Function(String event, dynamic data)? onEvent,
+ WidgetBuilder? loadingBuilder,
+ Widget Function(BuildContext context, Object error)? errorBuilder,
+ bool restrictNavigationToInitialPage = true,
+ }) {
+ final config = OnlyOfficeConfigFactory.create(
+ fileUrl: fileUrl,
+ mode: mode,
+ title: title,
+ allowDownload: allowDownload,
+ allowPrint: allowPrint,
+ user: user,
+ customization: customization,
+ tokenFactory: tokenFactory,
+ extra: extra,
+ );
+
+ return YxOnlyOfficeViewer(
+ key: key,
+ serverUrl: serverUrl,
+ config: config,
+ onError: onError,
+ onAppClose: onAppClose,
+ onDownloadAs: onDownloadAs,
+ onRequestSaveAs: onRequestSaveAs,
+ onRequestInsertImage: onRequestInsertImage,
+ onDocumentStateChange: onDocumentStateChange,
+ onMetaChange: onMetaChange,
+ onMakeActionLink: onMakeActionLink,
+ onEvent: onEvent,
+ loadingBuilder: loadingBuilder,
+ errorBuilder: errorBuilder,
+ restrictNavigationToInitialPage: restrictNavigationToInitialPage,
+ );
+ }
+
+ /// Legacy convenience constructor for viewing a document.
+ @Deprecated('Use YxOnlyOfficeViewer.create with mode="view" instead')
+ factory YxOnlyOfficeViewer.view({
+ Key? key,
+ required String serverUrl,
+ required String fileUrl,
+ String? title,
+ bool allowDownload = true,
+ bool allowPrint = false,
+ OnlyOfficeUser? user,
+ OnlyOfficeCustomization? customization,
+ OnlyOfficeJwtSigner? tokenFactory,
+ Map? extra,
+ Function(String)? onError,
+ Function()? onAppClose,
+ Function(String, String)? onDownloadAs,
+ WidgetBuilder? loadingBuilder,
+ Widget Function(BuildContext context, Object error)? errorBuilder,
+ bool restrictNavigationToInitialPage = true,
+ }) {
+ return YxOnlyOfficeViewer.create(
+ key: key,
+ serverUrl: serverUrl,
+ fileUrl: fileUrl,
+ mode: 'view',
+ title: title,
+ allowDownload: allowDownload,
+ allowPrint: allowPrint,
+ user: user,
+ customization: customization,
+ tokenFactory: tokenFactory,
+ extra: extra,
+ onError: onError,
+ onAppClose: onAppClose,
+ onDownloadAs: onDownloadAs,
+ loadingBuilder: loadingBuilder,
+ errorBuilder: errorBuilder,
+ restrictNavigationToInitialPage: restrictNavigationToInitialPage,
+ );
+ }
+
+ @override
+ State createState() => _YxOnlyOfficeViewerState();
+}
+
+class _YxOnlyOfficeViewerState extends State {
+ late final WebViewController _controller;
+ bool _isLoading = true;
+ Object? _error;
+ String? _lastHtmlSignature;
+
+ @override
+ void initState() {
+ super.initState();
+
+ // Initialize the WebViewController
+ late final PlatformWebViewControllerCreationParams params;
+ if (WebViewPlatform.instance is WebKitWebViewPlatform) {
+ params = WebKitWebViewControllerCreationParams(
+ allowsInlineMediaPlayback: true,
+ mediaTypesRequiringUserAction: const {},
+ );
+ } else {
+ params = const PlatformWebViewControllerCreationParams();
+ }
+
+ final WebViewController controller = WebViewController.fromPlatformCreationParams(params);
+
+ controller
+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
+ ..setBackgroundColor(const Color(0x00000000))
+ ..setNavigationDelegate(
+ NavigationDelegate(
+ onPageStarted: (String url) {},
+ onPageFinished: (String url) {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ },
+ onNavigationRequest: (NavigationRequest request) {
+ if (!request.isMainFrame) {
+ return NavigationDecision.navigate;
+ }
+ if (!widget.restrictNavigationToInitialPage) {
+ return NavigationDecision.navigate;
+ }
+ if (request.url.startsWith('about:blank')) {
+ return NavigationDecision.navigate;
+ }
+ // Prevent the WebView from navigating away from the local HTML shell.
+ return NavigationDecision.prevent;
+ },
+ onWebResourceError: (WebResourceError error) {
+ debugPrint("WebResourceError: ${error.description}");
+ widget.onError?.call("WebResourceError: ${error.description}");
+ if (mounted) {
+ setState(() {
+ _error = error;
+ });
+ }
+ },
+ ),
+ )
+ ..addJavaScriptChannel(
+ 'OnlyOfficeChannel',
+ onMessageReceived: (JavaScriptMessage message) {
+ _handleMessage(message.message);
+ },
+ );
+
+ // Android-specific configuration
+ if (controller.platform is AndroidWebViewController) {
+ final AndroidWebViewController androidController = controller.platform as AndroidWebViewController;
+
+ // Enable mixed content logic if needed and media playback
+ // We can't await here in initState, but we can fire and forget or use unawaited
+ androidController.setMediaPlaybackRequiresUserGesture(false);
+ }
+
+ _controller = controller;
+ _loadContent(forceReload: true);
+ }
+
+ @override
+ void didUpdateWidget(covariant YxOnlyOfficeViewer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final shouldReload =
+ _computeSignature(serverUrl: widget.serverUrl, config: widget.config) !=
+ _computeSignature(serverUrl: oldWidget.serverUrl, config: oldWidget.config);
+
+ if (shouldReload) {
+ setState(() {
+ _isLoading = true;
+ });
+ _loadContent(forceReload: true);
+ }
+ }
+
+ void _handleMessage(String message) {
+ try {
+ final Map data = jsonDecode(message);
+ final String event = data['event'];
+ final dynamic payload = data['data'];
+
+ // Universal event handler
+ widget.onEvent?.call(event, payload);
+
+ switch (event) {
+ case 'onAppClose':
+ widget.onAppClose?.call();
+ break;
+ case 'onDownloadAs':
+ if (payload is Map) {
+ final fileType = payload['fileType']?.toString() ?? '';
+ final url = payload['url']?.toString() ?? '';
+ widget.onDownloadAs?.call(fileType, url);
+ } else {
+ // Fallback if payload is not a map as expected, or handle appropriately
+ debugPrint("onDownloadAs received unexpected payload: $payload");
+ }
+ break;
+ case 'onError':
+ widget.onError?.call(payload.toString());
+ if (mounted) {
+ setState(() {
+ _error = payload;
+ });
+ }
+ break;
+ case 'onRequestSaveAs':
+ widget.onRequestSaveAs?.call(payload);
+ break;
+ case 'onRequestInsertImage':
+ widget.onRequestInsertImage?.call(payload);
+ break;
+ case 'onDocumentStateChange':
+ widget.onDocumentStateChange?.call(payload);
+ break;
+ case 'onMetaChange':
+ widget.onMetaChange?.call(payload);
+ break;
+ case 'onMakeActionLink':
+ widget.onMakeActionLink?.call(payload);
+ break;
+ default:
+ debugPrint("Unknown event: $event");
+ }
+ } catch (e) {
+ debugPrint("Error parsing message: $e");
+ }
+ }
+
+ void _loadContent({bool forceReload = false}) {
+ final signature = _computeSignature(serverUrl: widget.serverUrl, config: widget.config);
+
+ if (!forceReload && signature == _lastHtmlSignature) {
+ return;
+ }
+
+ _lastHtmlSignature = signature;
+ final String htmlContent = OnlyOfficeHtmlBuilder.build(serverUrl: widget.serverUrl, config: widget.config);
+ _controller.loadHtmlString(htmlContent);
+ }
+
+ String _computeSignature({required String serverUrl, required OnlyOfficeConfig config}) {
+ final normalizedServerUrl = OnlyOfficeHtmlBuilder.normalizeServerUrl(serverUrl);
+ final configJson = config.toJsonString();
+ return '$normalizedServerUrl|$configJson';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: [
+ WebViewWidget(controller: _controller),
+ if (_error != null)
+ Positioned.fill(
+ child:
+ widget.errorBuilder?.call(context, _error!) ??
+ Container(
+ color: Colors.white,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error, color: Colors.red, size: 48),
+ const SizedBox(height: 16),
+ const Text('Failed to load document', style: TextStyle(fontSize: 16)),
+ const SizedBox(height: 8),
+ Text(
+ _error.toString(),
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.grey),
+ ),
+ ],
+ ),
+ ),
+ ),
+ )
+ else if (_isLoading)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: widget.loadingBuilder?.call(context) ?? const Center(child: CircularProgressIndicator()),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/yx_only_office_flutter.dart b/lib/yx_only_office_flutter.dart
new file mode 100644
index 0000000..738bde1
--- /dev/null
+++ b/lib/yx_only_office_flutter.dart
@@ -0,0 +1,5 @@
+library;
+
+export 'src/onlyoffice_config.dart';
+export 'src/onlyoffice_html_builder.dart';
+export 'src/onlyoffice_viewer.dart';
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..5b1b05d
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,32 @@
+name: yx_only_office_flutter
+description: "Flexible Flutter plugin for embedding ONLYOFFICE editors."
+version: 0.1.0
+homepage:
+
+environment:
+ sdk: ^3.9.2
+ flutter: '>=3.3.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ plugin_platform_interface: ^2.0.2
+ crypto: ^3.0.7
+ webview_flutter: ^4.0.0
+ webview_flutter_android: ^3.0.0
+ webview_flutter_wkwebview: ^3.0.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^5.0.0
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ package: com.yuanxuan.yx_only_office_flutter
+ pluginClass: YxOnlyOfficeFlutterPlugin
+ ios:
+ pluginClass: YxOnlyOfficeFlutterPlugin
+
diff --git a/test/onlyoffice_html_builder_test.dart b/test/onlyoffice_html_builder_test.dart
new file mode 100644
index 0000000..1756db2
--- /dev/null
+++ b/test/onlyoffice_html_builder_test.dart
@@ -0,0 +1,74 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:yx_only_office_flutter/src/onlyoffice_config.dart';
+import 'package:yx_only_office_flutter/src/onlyoffice_html_builder.dart';
+
+void main() {
+ group('OnlyOfficeHtmlBuilder', () {
+ test('normalizes server URL and injects config JSON', () {
+ final config = OnlyOfficeConfig(
+ document: const OnlyOfficeDocument(
+ fileType: 'docx',
+ key: 'abc',
+ title: 'demo',
+ url: 'https://example.com/demo.docx',
+ ),
+ documentType: 'text',
+ editorConfig: const OnlyOfficeEditorConfig(mode: 'view', lang: 'zh-CN'),
+ type: 'mobile',
+ token: 'token',
+ );
+
+ final html = OnlyOfficeHtmlBuilder.build(
+ serverUrl: 'https://document.23544.com/',
+ config: config,
+ );
+
+ expect(
+ html.contains(
+ 'src="https://document.23544.com/web-apps/apps/api/documents/api.js"',
+ ),
+ isTrue,
+ );
+ expect(html.contains(config.toJsonString()), isTrue);
+ expect(html.contains('OnlyOfficeChannel'), isTrue);
+ });
+
+ test('wraps user-defined events instead of overwriting them', () {
+ final config = OnlyOfficeConfig(
+ document: const OnlyOfficeDocument(
+ fileType: 'pptx',
+ key: 'k',
+ title: 'slides',
+ url: 'https://example.com/slides.pptx',
+ ),
+ documentType: 'presentation',
+ editorConfig: const OnlyOfficeEditorConfig(),
+ type: 'desktop',
+ extra: const {
+ 'events': {'onError': 'noop'},
+ },
+ );
+
+ final html = OnlyOfficeHtmlBuilder.build(
+ serverUrl: 'https://doc.example.com',
+ config: config,
+ );
+
+ expect(html.contains('var userEvents = config.events || {};'), isTrue);
+ expect(html.contains('wrapEvent(userEvents.onError'), isTrue);
+ expect(
+ html.contains("Object.assign({}, userEvents, bridgeEvents)"),
+ isTrue,
+ );
+ });
+
+ test('trims whitespace when normalizing URLs', () {
+ expect(
+ OnlyOfficeHtmlBuilder.normalizeServerUrl(
+ ' https://document.23544.com/ ',
+ ),
+ equals('https://document.23544.com'),
+ );
+ });
+ });
+}