Multi-format document viewer: add image/text/office file type support
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,159 @@ 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
|
||||
final Uri url = Uri.parse(state.viewUrl);
|
||||
// In a real implementation, you'd use url_launcher
|
||||
// launchUrl(url);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} 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 +350,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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user