Compare commits

...

2 Commits

Author SHA1 Message Date
Leon Bösche
31ab3aad45 Fix Uri parsing errors in document viewer - viewUrl is already a Uri 2026-01-12 00:29:05 +01:00
Leon Bösche
923b0ede86 Multi-format document viewer: add image/text/office file type support 2026-01-12 00:22:12 +01:00
4 changed files with 407 additions and 341 deletions

View File

@@ -4,21 +4,28 @@ class DocumentCapabilities extends Equatable {
final bool canEdit;
final bool canAnnotate;
final bool isPdf;
final String mimeType;
const DocumentCapabilities({
required this.canEdit,
required this.canAnnotate,
required this.isPdf,
required this.mimeType,
});
@override
List<Object?> get props => [canEdit, canAnnotate, isPdf];
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
return DocumentCapabilities(
canEdit: json['canEdit'],
canAnnotate: json['canAnnotate'],
isPdf: json['isPdf'],
mimeType: json['mimeType'] ?? 'application/octet-stream',
);
}
bool get isImage => mimeType.startsWith('image/');
bool get isText => mimeType.startsWith('text/');
bool get isOffice => mimeType.contains('word') || mimeType.contains('spreadsheet') || mimeType.contains('presentation');
}

View File

@@ -10,6 +10,7 @@ import '../services/file_service.dart';
import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
// Modal version for overlay display
class DocumentViewerModal extends StatefulWidget {
@@ -177,24 +178,158 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
);
}
if (state is DocumentViewerReady) {
// Handle different file types based on MIME type
if (state.caps.isPdf) {
// Log the URL and auth being used for debugging
// Pass token via Authorization header - SfPdfViewer supports headers parameter
// PDF viewer using SfPdfViewer
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
);
} else {
} else if (state.caps.isImage) {
// Image viewer
return Container(
color: AppTheme.secondaryText,
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else if (state.caps.isText) {
// Text file viewer
return FutureBuilder<String>(
future: _fetchTextContent(
state.viewUrl.toString(),
state.token,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error loading text: ${snapshot.error}',
style: TextStyle(color: Colors.red[400]),
),
);
}
return SingleChildScrollView(
child: Container(
color: AppTheme.primaryBackground,
padding: const EdgeInsets.all(16),
child: SelectableText(
snapshot.data ?? '',
style: TextStyle(
color: AppTheme.primaryText,
fontFamily: 'Courier New',
fontSize: 13,
height: 1.5,
),
),
),
);
},
);
} else if (state.caps.isOffice) {
// Office document viewer - show placeholder with download option
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Text(
'Office Document Viewer\\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.caps.mimeType.contains('word')
? Icons.description
: state.caps.mimeType.contains('sheet')
? Icons.table_chart
: Icons.folder_open,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'Office Document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Download to view in your office application',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 14,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download File'),
onPressed: () {
// Open file download
// In a real implementation, you'd use url_launcher
// launchUrl(state.viewUrl);
},
),
],
),
),
);
} else {
// Unknown file type
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'File Type Not Supported',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'MIME Type: ${state.caps.mimeType}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
@@ -214,6 +349,23 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
);
}
Future<String> _fetchTextContent(String url, String token) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {'Authorization': 'Bearer $token'},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load text: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching text: $e');
}
}
@override
void dispose() {
_viewerBloc.close();
@@ -345,34 +497,174 @@ class _DocumentViewerState extends State<DocumentViewer> {
);
}
if (state is DocumentViewerReady) {
if (state.caps.isPdf) {
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, sessionState) {
String? token;
if (sessionState is SessionActive) {
token = sessionState.token;
}
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, sessionState) {
String? token;
if (sessionState is SessionActive) {
token = sessionState.token;
}
if (state.caps.isPdf) {
// PDF viewer using SfPdfViewer
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
);
},
);
} else {
// Placeholder for office docs iframe
return Container(
color: AppTheme.secondaryText,
child: Center(
child: Text(
'Office Document Viewer\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
),
),
);
}
} else if (state.caps.isImage) {
// Image viewer
return Container(
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else if (state.caps.isText) {
// Text file viewer
return FutureBuilder<String>(
future: _fetchTextContent(
state.viewUrl.toString(),
token ?? '',
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error loading text: ${snapshot.error}',
style: TextStyle(color: Colors.red[400]),
),
);
}
return SingleChildScrollView(
child: Container(
color: AppTheme.primaryBackground,
padding: const EdgeInsets.all(16),
child: SelectableText(
snapshot.data ?? '',
style: TextStyle(
color: AppTheme.primaryText,
fontFamily: 'Courier New',
fontSize: 13,
height: 1.5,
),
),
),
);
},
);
} else if (state.caps.isOffice) {
// Office document viewer - show placeholder with download option
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.caps.mimeType.contains('word')
? Icons.description
: state.caps.mimeType.contains('sheet')
? Icons.table_chart
: Icons.folder_open,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'Office Document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Download to view in your office application',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 14,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download File'),
onPressed: () {
// Open file download
// In a real implementation, you'd use url_launcher
// launchUrl(state.viewUrl);
},
),
],
),
),
);
} else {
// Unknown file type
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'File Type Not Supported',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'MIME Type: ${state.caps.mimeType}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
}
},
);
}
return const Center(child: Text('No document loaded'));
},
@@ -381,6 +673,23 @@ class _DocumentViewerState extends State<DocumentViewer> {
);
}
Future<String> _fetchTextContent(String url, String token) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {'Authorization': 'Bearer $token'},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load text: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching text: $e');
}
}
@override
void dispose() {
_viewerBloc.close();

View File

@@ -1,292 +0,0 @@
import '../models/file_item.dart';
import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../models/document_capabilities.dart';
import '../models/api_error.dart';
import '../repositories/file_repository.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
class MockFileRepository implements FileRepository {
final Map<String, List<FileItem>> _orgFiles = {};
final _uuid = const Uuid();
List<FileItem> _getFilesForOrg(String orgId) {
if (!_orgFiles.containsKey(orgId)) {
// Initialize with different files per org
if (orgId == 'org1') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Personal Documents',
path: '/Personal Documents',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'Photos',
path: '/Photos',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'resume.pdf',
path: '/resume.pdf',
type: FileType.file,
size: 1024,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'notes.txt',
path: '/notes.txt',
type: FileType.file,
size: 256,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org2') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Company Reports',
path: '/Company Reports',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'annual_report.pdf',
path: '/annual_report.pdf',
type: FileType.file,
size: 2048,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'presentation.pptx',
path: '/presentation.pptx',
type: FileType.file,
size: 4096,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org3') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Project Code',
path: '/Project Code',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'side_project.dart',
path: '/side_project.dart',
type: FileType.file,
size: 512,
lastModified: DateTime.now(),
),
];
} else {
// Default for new orgs
_orgFiles[orgId] = [];
}
}
return _orgFiles[orgId]!;
}
@override
Future<List<FileItem>> getFiles(String orgId, String path) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
if (path == '/') {
return files.where((f) => !f.path.substring(1).contains('/')).toList();
} else {
return files
.where((f) => f.path.startsWith('$path/') && f.path != path)
.toList();
}
}
@override
Future<FileItem?> getFile(String orgId, String path) async {
final files = _getFilesForOrg(orgId);
final index = files.indexWhere((f) => f.path == path);
return index != -1 ? files[index] : null;
}
@override
Future<EditorSession> requestEditorSession(
String orgId,
String fileId,
) async {
await Future.delayed(const Duration(seconds: 1));
// Mock: determine editability
final isEditable =
fileId.endsWith('.docx') ||
fileId.endsWith('.xlsx') ||
fileId.endsWith('.pptx');
final editUrl = Uri.parse(
'https://office.b0esche.cloud/editor/$orgId/$fileId?editable=$isEditable',
);
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
return EditorSession(
editUrl: editUrl,
token: 'mock-editor-token',
readOnly: !isEditable,
expiresAt: expiresAt,
);
}
@override
Future<void> deleteFile(String orgId, String path) async {
final files = _getFilesForOrg(orgId);
files.removeWhere((f) => f.path == path);
}
@override
Future<void> createFolder(
String orgId,
String parentPath,
String folderName,
) async {
await Future.delayed(const Duration(seconds: 1));
final normalizedName = folderName.startsWith('/')
? folderName.substring(1)
: folderName;
final newPath = parentPath == '/'
? '/$normalizedName'
: '$parentPath/$normalizedName';
final files = _getFilesForOrg(orgId);
files.add(
FileItem(
id: _uuid.v4(),
name: normalizedName,
path: newPath,
type: FileType.folder,
lastModified: DateTime.now(),
),
);
}
@override
Future<void> moveFile(
String orgId,
String sourcePath,
String targetPath,
) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
final fileIndex = files.indexWhere((f) => f.path == sourcePath);
if (fileIndex != -1) {
final file = files[fileIndex];
final newName = file.path.split('/').last;
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
files[fileIndex] = FileItem(
id: file.id,
name: file.name,
path: newPath,
type: file.type,
size: file.size,
lastModified: DateTime.now(),
);
}
}
@override
Future<void> renameFile(String orgId, String path, String newName) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
final fileIndex = files.indexWhere((f) => f.path == path);
if (fileIndex != -1) {
final file = files[fileIndex];
final parentPath = p.dirname(path);
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
files[fileIndex] = FileItem(
id: file.id,
name: newName,
path: newPath,
type: file.type,
size: file.size,
lastModified: DateTime.now(),
);
}
}
@override
Future<List<FileItem>> searchFiles(String orgId, String query) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
return files
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
.toList();
}
@override
Future<void> uploadFile(String orgId, FileItem file) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
files.add(
FileItem(
id: _uuid.v4(),
name: file.name,
path: file.path,
type: file.type,
size: file.size,
lastModified: file.lastModified,
),
);
}
@override
Future<ViewerSession> requestViewerSession(
String orgId,
String fileId,
) async {
await Future.delayed(const Duration(seconds: 1));
if (fileId.contains('forbidden')) {
throw ApiError(
code: 'permission_denied',
message: 'Access denied',
status: 403,
);
}
if (fileId.contains('notfound')) {
throw ApiError(code: 'not_found', message: 'File not found', status: 404);
}
// Mock: assume fileId is path, determine if PDF
final isPdf = fileId.endsWith('.pdf');
final caps = DocumentCapabilities(
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
canAnnotate: isPdf,
isPdf: isPdf,
);
// Mock URL
final viewUrl = Uri.parse(
'https://office.b0esche.cloud/viewer/$orgId/$fileId',
);
final token = 'mock-viewer-token';
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
return ViewerSession(
viewUrl: viewUrl,
capabilities: caps,
token: token,
expiresAt: expiresAt,
);
}
@override
Future<void> saveAnnotations(
String orgId,
String fileId,
List<Annotation> annotations,
) async {
await Future.delayed(const Duration(seconds: 2));
// Mock: just delay, assume success
}
}

View File

@@ -469,30 +469,33 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtM
}
downloadPath := fmt.Sprintf("%s://%s/orgs/%s/files/download?path=%s&token=%s", scheme, host, orgID.String(), url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine if it's a PDF based on file extension
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
}{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf},
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf, MimeType: mimeType},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] orgId=%s, fileId=%s, token_included=yes, isPdf=%v\n", orgID.String(), fileId, isPdf)
fmt.Printf("[VIEWER-SESSION] orgId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", orgID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
@@ -552,34 +555,38 @@ func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
}
downloadPath := fmt.Sprintf("%s://%s/user/files/download?path=%s&token=%s", scheme, host, url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine if it's a PDF based on file extension
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{
CanEdit: false,
CanAnnotate: isPdf,
IsPdf: isPdf,
MimeType: mimeType,
},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] userId=%s, fileId=%s, token_included=yes, isPdf=%v\n", userID.String(), fileId, isPdf)
fmt.Printf("[VIEWER-SESSION] userId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", userID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
@@ -1765,3 +1772,38 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas
io.Copy(w, resp.Body)
}
// getMimeType returns the MIME type based on file extension
func getMimeType(filename string) string {
lower := strings.ToLower(filename)
switch {
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(lower, ".txt"):
return "text/plain"
case strings.HasSuffix(lower, ".html"):
return "text/html"
case strings.HasSuffix(lower, ".json"):
return "application/json"
case strings.HasSuffix(lower, ".csv"):
return "text/csv"
case strings.HasSuffix(lower, ".doc"), strings.HasSuffix(lower, ".docx"):
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case strings.HasSuffix(lower, ".xls"), strings.HasSuffix(lower, ".xlsx"):
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case strings.HasSuffix(lower, ".ppt"), strings.HasSuffix(lower, ".pptx"):
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}