Add document creation feature with snackbar notifications and file service integration

This commit is contained in:
Leon Bösche
2026-01-13 23:27:04 +01:00
parent 744fbf87f5
commit 768f61337b
4 changed files with 328 additions and 0 deletions

View File

@@ -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 =

View File

@@ -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);
}
}

View File

@@ -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:

View File

@@ -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: