Add document creation feature with snackbar notifications and file service integration
This commit is contained in:
@@ -182,6 +182,181 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
_showRenameDialog(file);
|
||||
}
|
||||
|
||||
Future<String?> _showCreateDocumentDialog(BuildContext context) async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'New Document',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter document name',
|
||||
hintStyle: const TextStyle(color: AppTheme.secondaryText),
|
||||
suffixText: '.docx',
|
||||
suffixStyle: const TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: AppTheme.secondaryText.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final docName = controller.text.trim();
|
||||
if (docName.isNotEmpty) {
|
||||
Navigator.of(context).pop(docName);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Create',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.accentColor,
|
||||
decorationThickness: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _createAndOpenDocument(String currentPath) async {
|
||||
final docName = await _showCreateDocumentDialog(context);
|
||||
if (docName == null || docName.isEmpty || !context.mounted) return;
|
||||
|
||||
// Show creating snackbar
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? snackController;
|
||||
snackController = ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppTheme.accentColor,
|
||||
backgroundColor: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Creating $docName.docx...',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: const Duration(days: 1),
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final fileService = getIt<FileService>();
|
||||
final fileId = await fileService.createDocument(
|
||||
widget.orgId,
|
||||
currentPath,
|
||||
docName,
|
||||
);
|
||||
|
||||
snackController.close();
|
||||
|
||||
// Refresh file list
|
||||
if (context.mounted) {
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(orgId: widget.orgId, path: currentPath),
|
||||
);
|
||||
|
||||
// Open editor with the new document
|
||||
_showDocumentEditor(widget.orgId, fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
snackController.close();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Failed to create document: $e',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRenameDialog(FileItem file) async {
|
||||
final TextEditingController controller = TextEditingController(
|
||||
text: file.name,
|
||||
@@ -958,6 +1133,18 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: () =>
|
||||
_createAndOpenDocument(state.currentPath),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.description),
|
||||
SizedBox(width: 8),
|
||||
Text('Create Document'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: () async {
|
||||
final folderName =
|
||||
@@ -1139,6 +1326,18 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: () =>
|
||||
_createAndOpenDocument(state.currentPath),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.description),
|
||||
SizedBox(width: 8),
|
||||
Text('Create Document'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ModernGlassButton(
|
||||
onPressed: () async {
|
||||
final folderName =
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:typed_data';
|
||||
import '../models/file_item.dart';
|
||||
import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import 'api_client.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:archive/archive.dart';
|
||||
|
||||
class FileService {
|
||||
final ApiClient _apiClient;
|
||||
@@ -225,4 +227,114 @@ class FileService {
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an empty .docx document and returns the created file's ID
|
||||
Future<String> createDocument(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
String fileName,
|
||||
) async {
|
||||
// Ensure filename has .docx extension
|
||||
final docxName = fileName.endsWith('.docx') ? fileName : '$fileName.docx';
|
||||
|
||||
// Generate minimal valid DOCX file (Office Open XML format)
|
||||
final bytes = _generateEmptyDocx();
|
||||
|
||||
// Construct proper path
|
||||
String fullPath;
|
||||
if (parentPath == '/') {
|
||||
fullPath = '/$docxName';
|
||||
} else {
|
||||
final cleanParent = parentPath.endsWith('/')
|
||||
? parentPath.substring(0, parentPath.length - 1)
|
||||
: parentPath;
|
||||
fullPath = '$cleanParent/$docxName';
|
||||
}
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
'path': fullPath,
|
||||
'file': MultipartFile.fromBytes(bytes, filename: docxName),
|
||||
});
|
||||
|
||||
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||
final response = await _apiClient.post(
|
||||
endpoint,
|
||||
data: formData,
|
||||
fromJson: (d) => d as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
// Return the file ID from the response
|
||||
return response['id'] as String;
|
||||
}
|
||||
|
||||
/// Generates a minimal valid DOCX file (empty document)
|
||||
Uint8List _generateEmptyDocx() {
|
||||
final archive = Archive();
|
||||
|
||||
// [Content_Types].xml - defines content types
|
||||
const contentTypes =
|
||||
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'[Content_Types].xml',
|
||||
contentTypes.length,
|
||||
Uint8List.fromList(contentTypes.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// _rels/.rels - root relationships
|
||||
const rootRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'_rels/.rels',
|
||||
rootRels.length,
|
||||
Uint8List.fromList(rootRels.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// word/document.xml - the actual document content (empty)
|
||||
const documentXml =
|
||||
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p>
|
||||
<w:r>
|
||||
<w:t></w:t>
|
||||
</w:r>
|
||||
</w:p>
|
||||
</w:body>
|
||||
</w:document>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'word/document.xml',
|
||||
documentXml.length,
|
||||
Uint8List.fromList(documentXml.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// word/_rels/document.xml.rels - document relationships (empty but required)
|
||||
const docRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
</Relationships>''';
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
'word/_rels/document.xml.rels',
|
||||
docRels.length,
|
||||
Uint8List.fromList(docRels.codeUnits),
|
||||
),
|
||||
);
|
||||
|
||||
// Encode as ZIP
|
||||
final zipEncoder = ZipEncoder();
|
||||
final zipData = zipEncoder.encode(archive);
|
||||
return Uint8List.fromList(zipData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -896,6 +904,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -57,6 +57,7 @@ dependencies:
|
||||
syncfusion_flutter_pdfviewer: ^31.1.21
|
||||
web: ^1.1.0
|
||||
http: ^1.2.0
|
||||
archive: ^4.0.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user