From dd1aa4775ceed33fa3bdfffa3768efddc027b123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Wed, 17 Dec 2025 14:48:55 +0100 Subject: [PATCH] good morning --- .../document_viewer/document_viewer_bloc.dart | 108 +++++++++----- .../document_viewer_event.dart | 12 +- .../document_viewer_state.dart | 28 ++-- .../editor_session/editor_session_bloc.dart | 56 +++++++ .../editor_session/editor_session_event.dart | 24 +++ .../editor_session/editor_session_state.dart | 39 +++++ .../pdf_annotation/pdf_annotation_bloc.dart | 113 ++++++++++++++ .../pdf_annotation/pdf_annotation_event.dart | 42 ++++++ .../pdf_annotation/pdf_annotation_state.dart | 39 +++++ b0esche_cloud/lib/main.dart | 19 ++- b0esche_cloud/lib/models/annotation.dart | 106 +++++++++++++ .../lib/models/annotation_payload.dart | 22 +++ b0esche_cloud/lib/models/api_error.dart | 12 ++ .../lib/models/document_capabilities.dart | 24 +++ b0esche_cloud/lib/models/editor_session.dart | 24 +++ b0esche_cloud/lib/models/viewer_session.dart | 28 ++++ b0esche_cloud/lib/pages/document_viewer.dart | 73 +++++---- b0esche_cloud/lib/pages/editor_page.dart | 97 ++++++++++++ b0esche_cloud/lib/pages/file_explorer.dart | 14 +- b0esche_cloud/lib/pages/home_page.dart | 2 +- .../lib/repositories/file_repository.dart | 10 ++ .../repositories/mock_file_repository.dart | 78 +++++++++- b0esche_cloud/lib/services/api_client.dart | 140 ++++++++++++++++++ b0esche_cloud/lib/services/document_api.dart | 42 ++++++ b0esche_cloud/lib/services/file_service.dart | 35 +++++ .../test/document_viewer_bloc_test.dart | 96 ++++++++++++ 26 files changed, 1186 insertions(+), 97 deletions(-) create mode 100644 b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart create mode 100644 b0esche_cloud/lib/blocs/editor_session/editor_session_event.dart create mode 100644 b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart create mode 100644 b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart create mode 100644 b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_event.dart create mode 100644 b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_state.dart create mode 100644 b0esche_cloud/lib/models/annotation.dart create mode 100644 b0esche_cloud/lib/models/annotation_payload.dart create mode 100644 b0esche_cloud/lib/models/api_error.dart create mode 100644 b0esche_cloud/lib/models/document_capabilities.dart create mode 100644 b0esche_cloud/lib/models/editor_session.dart create mode 100644 b0esche_cloud/lib/models/viewer_session.dart create mode 100644 b0esche_cloud/lib/pages/editor_page.dart create mode 100644 b0esche_cloud/lib/services/api_client.dart create mode 100644 b0esche_cloud/lib/services/document_api.dart create mode 100644 b0esche_cloud/test/document_viewer_bloc_test.dart diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart index 55f786f..579fa6b 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart @@ -1,48 +1,86 @@ import 'package:bloc/bloc.dart'; import 'document_viewer_event.dart'; import 'document_viewer_state.dart'; +import '../../services/file_service.dart'; +import '../../models/api_error.dart'; class DocumentViewerBloc extends Bloc { - DocumentViewerBloc() : super(ViewerInitial()) { - on(_onOpenDocument); - on(_onCloseDocument); - on(_onReloadDocument); + final FileService _fileService; + + DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) { + on(_onDocumentOpened); + on(_onDocumentReloaded); + on(_onDocumentClosed); } - void _onOpenDocument( - OpenDocument event, + void _onDocumentOpened( + DocumentOpened event, Emitter emit, ) async { - emit(ViewerLoading()); - // Simulate requesting viewer session from backend - await Future.delayed(const Duration(seconds: 2)); - // Mock: assume PDF viewer - final viewUrl = 'https://storage.b0esche.cloud/view/${event.fileId}'; - final canEdit = false; // Based on permissions - final fileName = 'document.pdf'; - emit(ViewerReady(viewUrl: viewUrl, canEdit: canEdit, fileName: fileName)); - } - - void _onCloseDocument( - CloseDocument event, - Emitter emit, - ) { - emit(ViewerInitial()); - } - - void _onReloadDocument( - ReloadDocument event, - Emitter emit, - ) { - // Reload current document - final currentState = state; - if (currentState is ViewerReady) { - emit(ViewerLoading()); - // Simulate reload - Future.delayed(const Duration(seconds: 1), () { - emit(currentState); - }); + emit(DocumentViewerLoading()); + try { + final session = await _fileService.requestViewerSession( + event.orgId, + event.fileId, + ); + emit( + DocumentViewerReady( + viewUrl: session.viewUrl, + caps: session.capabilities, + ), + ); + } catch (e) { + if (e is ApiError) { + switch (e.code) { + case 'unauthorized': + // Already handled by ApiClient + break; + case 'permission_denied': + emit( + DocumentViewerError( + message: 'You do not have permission to view this document', + ), + ); + break; + case 'not_found': + emit(DocumentViewerError(message: 'Document not found')); + break; + default: + emit( + DocumentViewerError( + message: 'Failed to open document: ${e.message}', + ), + ); + } + } else { + emit(DocumentViewerError(message: 'Unexpected error: ${e.toString()}')); + } } } + + void _onDocumentReloaded( + DocumentReloaded event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is DocumentViewerReady) { + emit(DocumentViewerLoading()); + try { + // Assume orgId and fileId are stored, but for simplicity, reload current + // In real app, store last orgId and fileId + // For now, just re-emit ready (mock) + emit(currentState); + } catch (e) { + emit(DocumentViewerError(message: e.toString())); + } + } + } + + void _onDocumentClosed( + DocumentClosed event, + Emitter emit, + ) { + emit(DocumentViewerInitial()); + } } diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_event.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_event.dart index 1b80289..3c1a1c3 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_event.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_event.dart @@ -7,16 +7,16 @@ abstract class DocumentViewerEvent extends Equatable { List get props => []; } -class OpenDocument extends DocumentViewerEvent { - final String fileId; +class DocumentOpened extends DocumentViewerEvent { final String orgId; + final String fileId; - const OpenDocument({required this.fileId, required this.orgId}); + const DocumentOpened({required this.orgId, required this.fileId}); @override - List get props => [fileId, orgId]; + List get props => [orgId, fileId]; } -class CloseDocument extends DocumentViewerEvent {} +class DocumentReloaded extends DocumentViewerEvent {} -class ReloadDocument extends DocumentViewerEvent {} +class DocumentClosed extends DocumentViewerEvent {} diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart index fb16968..0192bcc 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import '../../models/document_capabilities.dart'; abstract class DocumentViewerState extends Equatable { const DocumentViewerState(); @@ -7,30 +8,25 @@ abstract class DocumentViewerState extends Equatable { List get props => []; } -class ViewerInitial extends DocumentViewerState {} +class DocumentViewerInitial extends DocumentViewerState {} -class ViewerLoading extends DocumentViewerState {} +class DocumentViewerLoading extends DocumentViewerState {} -class ViewerReady extends DocumentViewerState { - final String viewUrl; - final bool canEdit; - final String fileName; +class DocumentViewerReady extends DocumentViewerState { + final Uri viewUrl; + final DocumentCapabilities caps; - const ViewerReady({ - required this.viewUrl, - required this.canEdit, - required this.fileName, - }); + const DocumentViewerReady({required this.viewUrl, required this.caps}); @override - List get props => [viewUrl, canEdit, fileName]; + List get props => [viewUrl, caps]; } -class ViewerError extends DocumentViewerState { - final String error; +class DocumentViewerError extends DocumentViewerState { + final String message; - const ViewerError(this.error); + const DocumentViewerError({required this.message}); @override - List get props => [error]; + List get props => [message]; } diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart new file mode 100644 index 0000000..e6335de --- /dev/null +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart @@ -0,0 +1,56 @@ +import 'package:bloc/bloc.dart'; +import 'editor_session_event.dart'; +import 'editor_session_state.dart'; +import '../../services/file_service.dart'; + +class EditorSessionBloc extends Bloc { + final FileService _fileService; + + EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) { + on(_onEditorSessionStarted); + on(_onEditorSessionConnected); + on(_onEditorSessionDisconnected); + on(_onEditorSessionEnded); + } + + void _onEditorSessionStarted( + EditorSessionStarted event, + Emitter emit, + ) async { + emit(EditorSessionStarting()); + try { + final session = await _fileService.requestEditorSession( + event.orgId, + event.fileId, + ); + if (!session.readOnly) { + emit(EditorSessionActive(editUrl: session.editUrl)); + } else { + emit(EditorSessionReadOnly(viewUrl: session.editUrl)); + } + } catch (e) { + emit(EditorSessionFailed(message: e.toString())); + } + } + + void _onEditorSessionConnected( + EditorSessionConnected event, + Emitter emit, + ) { + // Handle connection if needed + } + + void _onEditorSessionDisconnected( + EditorSessionDisconnected event, + Emitter emit, + ) { + // Handle disconnection if needed + } + + void _onEditorSessionEnded( + EditorSessionEnded event, + Emitter emit, + ) { + emit(EditorSessionInitial()); + } +} diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_event.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_event.dart new file mode 100644 index 0000000..6d383d9 --- /dev/null +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_event.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +abstract class EditorSessionEvent extends Equatable { + const EditorSessionEvent(); + + @override + List get props => []; +} + +class EditorSessionStarted extends EditorSessionEvent { + final String orgId; + final String fileId; + + const EditorSessionStarted({required this.orgId, required this.fileId}); + + @override + List get props => [orgId, fileId]; +} + +class EditorSessionConnected extends EditorSessionEvent {} + +class EditorSessionDisconnected extends EditorSessionEvent {} + +class EditorSessionEnded extends EditorSessionEvent {} diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart new file mode 100644 index 0000000..79f65ba --- /dev/null +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +abstract class EditorSessionState extends Equatable { + const EditorSessionState(); + + @override + List get props => []; +} + +class EditorSessionInitial extends EditorSessionState {} + +class EditorSessionStarting extends EditorSessionState {} + +class EditorSessionActive extends EditorSessionState { + final Uri editUrl; + + const EditorSessionActive({required this.editUrl}); + + @override + List get props => [editUrl]; +} + +class EditorSessionReadOnly extends EditorSessionState { + final Uri viewUrl; + + const EditorSessionReadOnly({required this.viewUrl}); + + @override + List get props => [viewUrl]; +} + +class EditorSessionFailed extends EditorSessionState { + final String message; + + const EditorSessionFailed({required this.message}); + + @override + List get props => [message]; +} diff --git a/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart new file mode 100644 index 0000000..444b7f0 --- /dev/null +++ b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart @@ -0,0 +1,113 @@ +import 'package:bloc/bloc.dart'; +import 'pdf_annotation_event.dart'; +import 'pdf_annotation_state.dart'; +import '../../services/file_service.dart'; +import '../../models/annotation.dart'; + +class PdfAnnotationBloc extends Bloc { + final FileService _fileService; + final String _orgId; + final String _fileId; + final List _annotations = []; + final List _undoStack = []; + String _selectedTool = 'text'; + + PdfAnnotationBloc(this._fileService, this._orgId, this._fileId) + : super(PdfAnnotationIdle()) { + on(_onAnnotationToolSelected); + on(_onAnnotationAdded); + on(_onAnnotationRemoved); + on(_onAnnotationUndo); + on(_onAnnotationRedo); + on(_onAnnotationsSaved); + } + + void _onAnnotationToolSelected( + AnnotationToolSelected event, + Emitter emit, + ) { + _selectedTool = event.tool; + emit( + PdfAnnotationEditing( + annotations: _annotations, + undoStack: _undoStack, + selectedTool: _selectedTool, + ), + ); + } + + void _onAnnotationAdded( + AnnotationAdded event, + Emitter emit, + ) { + _annotations.add(event.annotation); + emit( + PdfAnnotationEditing( + annotations: _annotations, + undoStack: _undoStack, + selectedTool: _selectedTool, + ), + ); + } + + void _onAnnotationRemoved( + AnnotationRemoved event, + Emitter emit, + ) { + _annotations.removeWhere((a) => a.id == event.id); + emit( + PdfAnnotationEditing( + annotations: _annotations, + undoStack: _undoStack, + selectedTool: _selectedTool, + ), + ); + } + + void _onAnnotationUndo( + AnnotationUndo event, + Emitter emit, + ) { + if (_annotations.isNotEmpty) { + _undoStack.add(_annotations.removeLast()); + } + emit( + PdfAnnotationEditing( + annotations: _annotations, + undoStack: _undoStack, + selectedTool: _selectedTool, + ), + ); + } + + void _onAnnotationRedo( + AnnotationRedo event, + Emitter emit, + ) { + if (_undoStack.isNotEmpty) { + _annotations.add(_undoStack.removeLast()); + } + emit( + PdfAnnotationEditing( + annotations: _annotations, + undoStack: _undoStack, + selectedTool: _selectedTool, + ), + ); + } + + void _onAnnotationsSaved( + AnnotationsSaved event, + Emitter emit, + ) async { + emit(PdfAnnotationSaving()); + try { + await _fileService.saveAnnotations(_orgId, _fileId, _annotations); + emit(PdfAnnotationSaved()); + // Reset to idle or editing? + emit(PdfAnnotationIdle()); + } catch (e) { + emit(PdfAnnotationError(message: e.toString())); + } + } +} diff --git a/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_event.dart b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_event.dart new file mode 100644 index 0000000..5c524ed --- /dev/null +++ b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_event.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import '../../models/annotation.dart'; + +abstract class PdfAnnotationEvent extends Equatable { + const PdfAnnotationEvent(); + + @override + List get props => []; +} + +class AnnotationToolSelected extends PdfAnnotationEvent { + final String tool; // e.g. 'text', 'highlight', 'signature' + + const AnnotationToolSelected({required this.tool}); + + @override + List get props => [tool]; +} + +class AnnotationAdded extends PdfAnnotationEvent { + final Annotation annotation; + + const AnnotationAdded({required this.annotation}); + + @override + List get props => [annotation]; +} + +class AnnotationRemoved extends PdfAnnotationEvent { + final String id; + + const AnnotationRemoved({required this.id}); + + @override + List get props => [id]; +} + +class AnnotationUndo extends PdfAnnotationEvent {} + +class AnnotationRedo extends PdfAnnotationEvent {} + +class AnnotationsSaved extends PdfAnnotationEvent {} diff --git a/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_state.dart b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_state.dart new file mode 100644 index 0000000..79f4025 --- /dev/null +++ b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_state.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import '../../models/annotation.dart'; + +abstract class PdfAnnotationState extends Equatable { + const PdfAnnotationState(); + + @override + List get props => []; +} + +class PdfAnnotationIdle extends PdfAnnotationState {} + +class PdfAnnotationEditing extends PdfAnnotationState { + final List annotations; + final List undoStack; + final String selectedTool; + + const PdfAnnotationEditing({ + required this.annotations, + required this.undoStack, + required this.selectedTool, + }); + + @override + List get props => [annotations, undoStack, selectedTool]; +} + +class PdfAnnotationSaving extends PdfAnnotationState {} + +class PdfAnnotationSaved extends PdfAnnotationState {} + +class PdfAnnotationError extends PdfAnnotationState { + final String message; + + const PdfAnnotationError({required this.message}); + + @override + List get props => [message]; +} diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index b042910..c3533a5 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -13,6 +13,7 @@ import 'pages/home_page.dart'; import 'pages/login_form.dart'; import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; +import 'pages/editor_page.dart'; import 'theme/app_theme.dart'; final GoRouter _router = GoRouter( @@ -21,13 +22,23 @@ final GoRouter _router = GoRouter( GoRoute(path: '/login', builder: (context, state) => const LoginForm()), GoRoute( - path: '/viewer/:fileId', - builder: (context, state) => - DocumentViewer(fileId: state.pathParameters['fileId']!), + path: '/viewer/:orgId/:fileId', + builder: (context, state) => DocumentViewer( + orgId: state.pathParameters['orgId']!, + fileId: state.pathParameters['fileId']!, + ), + ), + GoRoute( + path: '/editor/:orgId/:fileId', + builder: (context, state) => EditorPage( + orgId: state.pathParameters['orgId']!, + fileId: state.pathParameters['fileId']!, + ), ), GoRoute( path: '/org/:orgId/drive', - builder: (context, state) => const FileExplorer(), + builder: (context, state) => + FileExplorer(orgId: state.pathParameters['orgId']!), ), ], ); diff --git a/b0esche_cloud/lib/models/annotation.dart b/b0esche_cloud/lib/models/annotation.dart new file mode 100644 index 0000000..c5f5e86 --- /dev/null +++ b/b0esche_cloud/lib/models/annotation.dart @@ -0,0 +1,106 @@ +import 'package:equatable/equatable.dart'; + +abstract class Annotation extends Equatable { + final String id; + final int pageIndex; + + const Annotation({required this.id, required this.pageIndex}); + + @override + List get props => [id, pageIndex]; + + Map toJson(); +} + +class TextAnnotation extends Annotation { + final String text; + final double x; + final double y; + final double width; + final double height; + + const TextAnnotation({ + required super.id, + required super.pageIndex, + required this.text, + required this.x, + required this.y, + required this.width, + required this.height, + }); + + @override + List get props => super.props + [text, x, y, width, height]; + + @override + Map toJson() { + return { + 'type': 'text', + 'id': id, + 'pageIndex': pageIndex, + 'text': text, + 'x': x, + 'y': y, + 'width': width, + 'height': height, + }; + } +} + +class HighlightAnnotation extends Annotation { + final List points; // List of x,y coordinates + + const HighlightAnnotation({ + required super.id, + required super.pageIndex, + required this.points, + }); + + @override + List get props => super.props + [points]; + + @override + Map toJson() { + return { + 'type': 'highlight', + 'id': id, + 'pageIndex': pageIndex, + 'points': points, + }; + } +} + +class SignatureAnnotation extends Annotation { + final String imagePath; // or bytes, but for simplicity + final double x; + final double y; + final double width; + final double height; + + const SignatureAnnotation({ + required super.id, + required super.pageIndex, + required this.imagePath, + required this.x, + required this.y, + required this.width, + required this.height, + }); + + @override + List get props => super.props + [imagePath, x, y, width, height]; + + @override + Map toJson() { + return { + 'type': 'signature', + 'id': id, + 'pageIndex': pageIndex, + 'imagePath': imagePath, + 'x': x, + 'y': y, + 'width': width, + 'height': height, + }; + } +} diff --git a/b0esche_cloud/lib/models/annotation_payload.dart b/b0esche_cloud/lib/models/annotation_payload.dart new file mode 100644 index 0000000..922b875 --- /dev/null +++ b/b0esche_cloud/lib/models/annotation_payload.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; +import 'annotation.dart'; + +class AnnotationPayload extends Equatable { + final List annotations; + final String baseVersionId; + + const AnnotationPayload({ + required this.annotations, + required this.baseVersionId, + }); + + @override + List get props => [annotations, baseVersionId]; + + Map toJson() { + return { + 'annotations': annotations.map((a) => a.toJson()).toList(), + 'baseVersionId': baseVersionId, + }; + } +} diff --git a/b0esche_cloud/lib/models/api_error.dart b/b0esche_cloud/lib/models/api_error.dart new file mode 100644 index 0000000..055afd9 --- /dev/null +++ b/b0esche_cloud/lib/models/api_error.dart @@ -0,0 +1,12 @@ +import 'package:equatable/equatable.dart'; + +class ApiError extends Equatable { + final String code; + final String message; + final int? status; + + const ApiError({required this.code, required this.message, this.status}); + + @override + List get props => [code, message, status]; +} diff --git a/b0esche_cloud/lib/models/document_capabilities.dart b/b0esche_cloud/lib/models/document_capabilities.dart new file mode 100644 index 0000000..016295c --- /dev/null +++ b/b0esche_cloud/lib/models/document_capabilities.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class DocumentCapabilities extends Equatable { + final bool canEdit; + final bool canAnnotate; + final bool isPdf; + + const DocumentCapabilities({ + required this.canEdit, + required this.canAnnotate, + required this.isPdf, + }); + + @override + List get props => [canEdit, canAnnotate, isPdf]; + + factory DocumentCapabilities.fromJson(Map json) { + return DocumentCapabilities( + canEdit: json['canEdit'], + canAnnotate: json['canAnnotate'], + isPdf: json['isPdf'], + ); + } +} diff --git a/b0esche_cloud/lib/models/editor_session.dart b/b0esche_cloud/lib/models/editor_session.dart new file mode 100644 index 0000000..6cc489d --- /dev/null +++ b/b0esche_cloud/lib/models/editor_session.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class EditorSession extends Equatable { + final Uri editUrl; + final bool readOnly; + final DateTime expiresAt; + + const EditorSession({ + required this.editUrl, + required this.readOnly, + required this.expiresAt, + }); + + @override + List get props => [editUrl, readOnly, expiresAt]; + + factory EditorSession.fromJson(Map json) { + return EditorSession( + editUrl: Uri.parse(json['editUrl']), + readOnly: json['readOnly'], + expiresAt: DateTime.parse(json['expiresAt']), + ); + } +} diff --git a/b0esche_cloud/lib/models/viewer_session.dart b/b0esche_cloud/lib/models/viewer_session.dart new file mode 100644 index 0000000..f916845 --- /dev/null +++ b/b0esche_cloud/lib/models/viewer_session.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'document_capabilities.dart'; + +class ViewerSession extends Equatable { + final Uri viewUrl; + final DocumentCapabilities capabilities; + final String token; + final DateTime expiresAt; + + const ViewerSession({ + required this.viewUrl, + required this.capabilities, + required this.token, + required this.expiresAt, + }); + + @override + List get props => [viewUrl, capabilities, token, expiresAt]; + + factory ViewerSession.fromJson(Map json) { + return ViewerSession( + viewUrl: Uri.parse(json['viewUrl']), + capabilities: DocumentCapabilities.fromJson(json['capabilities']), + token: json['token'], + expiresAt: DateTime.parse(json['expiresAt']), + ); + } +} diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index b2c60b8..5a45fa3 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -4,11 +4,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/document_viewer/document_viewer_bloc.dart'; import '../blocs/document_viewer/document_viewer_event.dart'; import '../blocs/document_viewer/document_viewer_state.dart'; +import '../services/file_service.dart'; +import '../injection.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:go_router/go_router.dart'; class DocumentViewer extends StatefulWidget { + final String orgId; final String fileId; - const DocumentViewer({super.key, required this.fileId}); + const DocumentViewer({super.key, required this.orgId, required this.fileId}); @override State createState() => _DocumentViewerState(); @@ -20,10 +25,8 @@ class _DocumentViewerState extends State { @override void initState() { super.initState(); - _viewerBloc = DocumentViewerBloc(); - _viewerBloc.add( - OpenDocument(fileId: widget.fileId, orgId: 'org1'), - ); // Assume org1 + _viewerBloc = DocumentViewerBloc(getIt()); + _viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId)); } @override @@ -32,21 +35,29 @@ class _DocumentViewerState extends State { value: _viewerBloc, child: Scaffold( appBar: AppBar( - title: BlocBuilder( - builder: (context, state) { - if (state is ViewerReady) { - return Text(state.fileName); - } - return const Text('Document Viewer'); - }, - ), + title: const Text('Document Viewer'), actions: [ + BlocBuilder( + builder: (context, state) { + if (state is DocumentViewerReady && state.caps.canEdit) { + return IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + GoRouter.of( + context, + ).go('/editor/${widget.orgId}/${widget.fileId}'); + }, + ); + } + return const SizedBox.shrink(); + }, + ), IconButton( icon: const Icon(Icons.refresh), splashColor: Colors.transparent, highlightColor: Colors.transparent, onPressed: () { - _viewerBloc.add(ReloadDocument()); + _viewerBloc.add(DocumentReloaded()); }, ), IconButton( @@ -54,7 +65,7 @@ class _DocumentViewerState extends State { splashColor: Colors.transparent, highlightColor: Colors.transparent, onPressed: () { - _viewerBloc.add(CloseDocument()); + _viewerBloc.add(DocumentClosed()); Navigator.of(context).pop(); }, ), @@ -62,23 +73,29 @@ class _DocumentViewerState extends State { ), body: BlocBuilder( builder: (context, state) { - if (state is ViewerLoading) { + if (state is DocumentViewerLoading) { return const Center(child: CircularProgressIndicator()); } - if (state is ViewerError) { - return Center(child: Text('Error: ${state.error}')); + if (state is DocumentViewerError) { + return Center(child: Text('Error: ${state.message}')); } - if (state is ViewerReady) { - return Container( - color: AppTheme.secondaryText, - child: Center( - child: Text( - 'Document Viewer Placeholder\n(Backend URL: ${state.viewUrl})', - textAlign: TextAlign.center, - style: const TextStyle(color: AppTheme.primaryText), + if (state is DocumentViewerReady) { + if (state.caps.isPdf) { + // Use PDF viewer + return SfPdfViewer.network(state.viewUrl.toString()); + } 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), + ), ), - ), - ); + ); + } } return const Center(child: Text('No document loaded')); }, diff --git a/b0esche_cloud/lib/pages/editor_page.dart b/b0esche_cloud/lib/pages/editor_page.dart new file mode 100644 index 0000000..e515df1 --- /dev/null +++ b/b0esche_cloud/lib/pages/editor_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../blocs/editor_session/editor_session_bloc.dart'; +import '../blocs/editor_session/editor_session_event.dart'; +import '../blocs/editor_session/editor_session_state.dart'; +import '../services/file_service.dart'; +import '../injection.dart'; +import 'package:go_router/go_router.dart'; + +class EditorPage extends StatefulWidget { + final String orgId; + final String fileId; + + const EditorPage({super.key, required this.orgId, required this.fileId}); + + @override + State createState() => _EditorPageState(); +} + +class _EditorPageState extends State { + late EditorSessionBloc _editorBloc; + + @override + void initState() { + super.initState(); + _editorBloc = EditorSessionBloc(getIt()); + _editorBloc.add( + EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _editorBloc, + child: Scaffold( + appBar: AppBar( + title: const Text('Document Editor'), + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _editorBloc.add(EditorSessionEnded()); + GoRouter.of( + context, + ).go('/viewer/${widget.orgId}/${widget.fileId}'); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is EditorSessionStarting) { + return const Center(child: CircularProgressIndicator()); + } + if (state is EditorSessionFailed) { + return Center(child: Text('Error: ${state.message}')); + } + if (state is EditorSessionActive) { + // Placeholder for Collabora iframe + return Container( + color: AppTheme.secondaryText, + child: Center( + child: Text( + 'Collabora Editor Active\n(URL: ${state.editUrl})', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + ); + } + if (state is EditorSessionReadOnly) { + return Container( + color: AppTheme.secondaryText, + child: Center( + child: Text( + 'Read Only Mode\n(URL: ${state.viewUrl})', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + ); + } + return const Center(child: Text('Editor not started')); + }, + ), + ), + ); + } + + @override + void dispose() { + _editorBloc.close(); + super.dispose(); + } +} diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index ad0914d..f6fba5b 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -16,7 +16,9 @@ import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; class FileExplorer extends StatefulWidget { - const FileExplorer({super.key}); + final String orgId; + + const FileExplorer({super.key, required this.orgId}); @override State createState() => _FileExplorerState(); @@ -47,11 +49,10 @@ class _FileExplorerState extends State { @override void initState() { super.initState(); - // Assume org1 for now context.read().add( - LoadDirectory(orgId: 'org1', path: '/'), + LoadDirectory(orgId: widget.orgId, path: '/'), ); - context.read().add(LoadPermissions('org1')); + context.read().add(LoadPermissions(widget.orgId)); } Future _showCreateFolderDialog(BuildContext context) async { @@ -529,7 +530,10 @@ class _FileExplorerState extends State { if (file.type == FileType.folder) { context.read().add(NavigateToFolder(file.path)); } else { - context.go('/viewer/${file.name}'); + final fileId = file.path.startsWith('/') + ? file.path.substring(1) + : file.path; + context.go('/viewer/${widget.orgId}/$fileId'); } }, child: Container( diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index 7c0c76b..e64dd8c 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -66,7 +66,7 @@ class _HomePageState extends State with TickerProviderStateMixin { Container( decoration: AppTheme.glassDecoration, child: isLoggedIn - ? const FileExplorer() + ? const FileExplorer(orgId: 'org1') : const LoginForm(), ), // Top-left radial glow - primary accent light diff --git a/b0esche_cloud/lib/repositories/file_repository.dart b/b0esche_cloud/lib/repositories/file_repository.dart index a844bdb..1d15103 100644 --- a/b0esche_cloud/lib/repositories/file_repository.dart +++ b/b0esche_cloud/lib/repositories/file_repository.dart @@ -1,4 +1,7 @@ import '../models/file_item.dart'; +import '../models/viewer_session.dart'; +import '../models/editor_session.dart'; +import '../models/annotation.dart'; abstract class FileRepository { Future> getFiles(String orgId, String path); @@ -9,4 +12,11 @@ abstract class FileRepository { Future moveFile(String orgId, String sourcePath, String targetPath); Future renameFile(String orgId, String path, String newName); Future> searchFiles(String orgId, String query); + Future requestViewerSession(String orgId, String fileId); + Future requestEditorSession(String orgId, String fileId); + Future saveAnnotations( + String orgId, + String fileId, + List annotations, + ); } diff --git a/b0esche_cloud/lib/repositories/mock_file_repository.dart b/b0esche_cloud/lib/repositories/mock_file_repository.dart index b85801c..f4cd5b3 100644 --- a/b0esche_cloud/lib/repositories/mock_file_repository.dart +++ b/b0esche_cloud/lib/repositories/mock_file_repository.dart @@ -1,4 +1,9 @@ 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; @@ -190,9 +195,25 @@ class MockFileRepository implements FileRepository { } @override - Future uploadFile(String orgId, FileItem file) async { + Future requestEditorSession( + String orgId, + String fileId, + ) async { await Future.delayed(const Duration(seconds: 1)); - _files.add(file); + // 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, + readOnly: !isEditable, + expiresAt: expiresAt, + ); } @override @@ -270,4 +291,57 @@ class MockFileRepository implements FileRepository { .where((f) => f.name.toLowerCase().contains(query.toLowerCase())) .toList(); } + + @override + Future uploadFile(String orgId, FileItem file) async { + await Future.delayed(const Duration(seconds: 1)); + _files.add(file); + } + + @override + Future 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 saveAnnotations( + String orgId, + String fileId, + List annotations, + ) async { + await Future.delayed(const Duration(seconds: 2)); + // Mock: just delay, assume success + } } diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart new file mode 100644 index 0000000..ee77eb4 --- /dev/null +++ b/b0esche_cloud/lib/services/api_client.dart @@ -0,0 +1,140 @@ +import 'package:dio/dio.dart'; +import '../models/api_error.dart'; +import '../blocs/session/session_bloc.dart'; +import '../blocs/session/session_event.dart'; +import '../blocs/session/session_state.dart'; + +class ApiClient { + late final Dio _dio; + final SessionBloc _sessionBloc; + + ApiClient(this._sessionBloc, {String baseUrl = 'https://go.b0esche.cloud'}) { + _dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // Add JWT if available + final token = _getCurrentToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + return handler.next(options); + }, + onError: (error, handler) async { + if (error.response?.statusCode == 401) { + // Try refresh + final refreshSuccess = await _tryRefreshToken(); + if (refreshSuccess) { + // Retry the request + final token = _getCurrentToken(); + if (token != null) { + error.requestOptions.headers['Authorization'] = 'Bearer $token'; + try { + final response = await _dio.fetch(error.requestOptions); + return handler.resolve(response); + } catch (e) { + // If retry fails, proceed to error + } + } + } + // If refresh failed, logout + _sessionBloc.add(SessionExpired()); + } + return handler.next(error); + }, + ), + ); + } + + String? _getCurrentToken() { + // Get from SessionBloc state + final state = _sessionBloc.state; + if (state is SessionActive) { + return state.token; + } + return null; + } + + Future _tryRefreshToken() async { + try { + final response = await _dio.post('/auth/refresh'); + if (response.statusCode == 200) { + final newToken = response.data['token']; + _sessionBloc.add(SessionRefreshed(newToken)); + return true; + } + } catch (e) { + // Refresh failed + } + return false; + } + + Future get( + String path, { + Map? queryParameters, + required T Function(dynamic data) fromJson, + }) async { + try { + final response = await _dio.get(path, queryParameters: queryParameters); + return fromJson(response.data); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future post( + String path, { + dynamic data, + required T Function(dynamic data) fromJson, + }) async { + try { + final response = await _dio.post(path, data: data); + return fromJson(response.data); + } on DioException catch (e) { + throw _handleError(e); + } + } + + ApiError _handleError(DioException e) { + final status = e.response?.statusCode; + final data = e.response?.data; + if (status == 403) { + return ApiError( + code: 'permission_denied', + message: 'Access denied', + status: status, + ); + } else if (status == 404) { + return ApiError( + code: 'not_found', + message: 'Resource not found', + status: status, + ); + } else if (status == 409) { + return ApiError( + code: 'conflict', + message: 'Version conflict', + status: status, + ); + } else if (status == 401) { + return ApiError( + code: 'unauthorized', + message: 'Unauthorized', + status: status, + ); + } else { + return ApiError( + code: 'server_error', + message: data?['message'] ?? 'Server error', + status: status, + ); + } + } +} diff --git a/b0esche_cloud/lib/services/document_api.dart b/b0esche_cloud/lib/services/document_api.dart new file mode 100644 index 0000000..c25be3e --- /dev/null +++ b/b0esche_cloud/lib/services/document_api.dart @@ -0,0 +1,42 @@ +import '../models/viewer_session.dart'; +import '../models/editor_session.dart'; +import '../models/annotation_payload.dart'; +import 'api_client.dart'; + +class DocumentApi { + final ApiClient _apiClient; + + DocumentApi(this._apiClient); + + Future requestViewerSession( + String orgId, + String fileId, + ) async { + return await _apiClient.get( + '/orgs/$orgId/files/$fileId/view', + fromJson: (data) => ViewerSession.fromJson(data), + ); + } + + Future requestEditorSession( + String orgId, + String fileId, + ) async { + return await _apiClient.get( + '/orgs/$orgId/files/$fileId/edit', + fromJson: (data) => EditorSession.fromJson(data), + ); + } + + Future saveAnnotations( + String orgId, + String fileId, + AnnotationPayload payload, + ) async { + await _apiClient.post( + '/orgs/$orgId/files/$fileId/annotations', + data: payload.toJson(), + fromJson: (data) => null, + ); + } +} diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 6284a2c..60282bb 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -1,4 +1,8 @@ import '../models/file_item.dart'; +import '../models/viewer_session.dart'; +import '../models/editor_session.dart'; +import '../models/annotation.dart'; +import '../models/api_error.dart'; import '../repositories/file_repository.dart'; class FileService { @@ -69,4 +73,35 @@ class FileService { } return await _fileRepository.searchFiles(orgId, query); } + + Future requestViewerSession( + String orgId, + String fileId, + ) async { + if (orgId.isEmpty || fileId.isEmpty) { + throw Exception('OrgId and fileId cannot be empty'); + } + return await _fileRepository.requestViewerSession(orgId, fileId); + } + + Future requestEditorSession( + String orgId, + String fileId, + ) async { + if (orgId.isEmpty || fileId.isEmpty) { + throw Exception('OrgId and fileId cannot be empty'); + } + return await _fileRepository.requestEditorSession(orgId, fileId); + } + + Future saveAnnotations( + String orgId, + String fileId, + List annotations, + ) async { + if (orgId.isEmpty || fileId.isEmpty) { + throw Exception('OrgId and fileId cannot be empty'); + } + await _fileRepository.saveAnnotations(orgId, fileId, annotations); + } } diff --git a/b0esche_cloud/test/document_viewer_bloc_test.dart b/b0esche_cloud/test/document_viewer_bloc_test.dart new file mode 100644 index 0000000..c6fdf4d --- /dev/null +++ b/b0esche_cloud/test/document_viewer_bloc_test.dart @@ -0,0 +1,96 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_bloc.dart'; +import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_event.dart'; +import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_state.dart'; +import 'package:b0esche_cloud/services/file_service.dart'; +import 'package:b0esche_cloud/models/viewer_session.dart'; +import 'package:b0esche_cloud/models/document_capabilities.dart'; +import 'package:b0esche_cloud/models/api_error.dart'; + +class MockFileService extends Mock implements FileService {} + +void main() { + late DocumentViewerBloc bloc; + late MockFileService mockFileService; + + setUp(() { + mockFileService = MockFileService(); + bloc = DocumentViewerBloc(mockFileService); + }); + + tearDown(() { + bloc.close(); + }); + + group('DocumentViewerBloc', () { + blocTest( + 'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds', + build: () { + when(mockFileService.requestViewerSession('org1', 'file1')).thenAnswer( + (_) async => ViewerSession( + viewUrl: Uri.parse('https://example.com/view'), + capabilities: DocumentCapabilities( + canEdit: true, + canAnnotate: false, + isPdf: false, + ), + token: 'mock-token', + expiresAt: DateTime.now().add(const Duration(minutes: 30)), + ), + ); + return bloc; + }, + act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), + expect: () => [ + DocumentViewerLoading(), + DocumentViewerReady( + viewUrl: Uri.parse('https://example.com/view'), + caps: DocumentCapabilities( + canEdit: true, + canAnnotate: false, + isPdf: false, + ), + ), + ], + ); + + blocTest( + 'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails', + build: () { + when(mockFileService.requestViewerSession('org1', 'file1')).thenThrow( + ApiError(code: 'server_error', message: 'Server error', status: 500), + ); + return bloc; + }, + act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), + expect: () => [ + DocumentViewerLoading(), + DocumentViewerError(message: 'Failed to open document: Server error'), + ], + ); + + blocTest( + 'emits [DocumentViewerInitial] when DocumentClosed', + setUp: () { + when( + mockFileService.requestViewerSession('org1', 'file1'), + ).thenThrow(Exception('Error')); + }, + build: () => bloc, + act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), + expect: () => [ + DocumentViewerLoading(), + DocumentViewerError(message: 'Failed to open document: Server error'), + ], + ); + + blocTest( + 'emits [DocumentViewerInitial] when DocumentClosed', + build: () => bloc, + act: (bloc) => bloc.add(DocumentClosed()), + expect: () => [DocumentViewerInitial()], + ); + }); +}