From 5b71c6b9d22193e232450282aa6d046ebb0e8776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Wed, 17 Dec 2025 14:53:29 +0100 Subject: [PATCH] docs first commit --- .../document_viewer/document_viewer_bloc.dart | 27 ++++-- .../document_viewer_state.dart | 2 + .../editor_session/editor_session_bloc.dart | 9 ++ .../editor_session/editor_session_state.dart | 2 + b0esche_cloud/lib/models/file_meta.dart | 24 +++++ b0esche_cloud/lib/pages/document_viewer.dart | 43 +++++++++ b0esche_cloud/lib/pages/editor_page.dart | 21 +++++ b0esche_cloud/lib/services/document_api.dart | 8 ++ b0esche_cloud/lib/services/file_service.dart | 1 - .../lib/widgets/collabora_frame.dart | 33 +++++++ .../test/document_viewer_bloc_test.dart | 90 +++++++++++-------- 11 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 b0esche_cloud/lib/models/file_meta.dart create mode 100644 b0esche_cloud/lib/widgets/collabora_frame.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 579fa6b..350993f 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:bloc/bloc.dart'; import 'document_viewer_event.dart'; import 'document_viewer_state.dart'; @@ -7,6 +8,7 @@ import '../../models/api_error.dart'; class DocumentViewerBloc extends Bloc { final FileService _fileService; + Timer? _expiryTimer; DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) { on(_onDocumentOpened); @@ -30,6 +32,12 @@ class DocumentViewerBloc caps: session.capabilities, ), ); + if (session.expiresAt.isAfter(DateTime.now())) { + _expiryTimer = Timer( + session.expiresAt.difference(DateTime.now()), + () => emit(DocumentViewerSessionExpired()), + ); + } } catch (e) { if (e is ApiError) { switch (e.code) { @@ -39,7 +47,14 @@ class DocumentViewerBloc case 'permission_denied': emit( DocumentViewerError( - message: 'You do not have permission to view this document', + message: 'You don\'t have access to this document.', + ), + ); + break; + case 'not_found': + emit( + DocumentViewerError( + message: 'This document no longer exists or was moved.', ), ); break; @@ -65,14 +80,15 @@ class DocumentViewerBloc ) async { final currentState = state; if (currentState is DocumentViewerReady) { + _expiryTimer?.cancel(); 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) + // Re-fetch session + // For simplicity, re-emit (in real, re-call the service) emit(currentState); + // Restart timer if needed } catch (e) { - emit(DocumentViewerError(message: e.toString())); + emit(DocumentViewerError(message: 'Unexpected error: ${e.toString()}')); } } } @@ -81,6 +97,7 @@ class DocumentViewerBloc DocumentClosed event, Emitter emit, ) { + _expiryTimer?.cancel(); emit(DocumentViewerInitial()); } } 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 0192bcc..e45e302 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart @@ -30,3 +30,5 @@ class DocumentViewerError extends DocumentViewerState { @override List get props => [message]; } + +class DocumentViewerSessionExpired extends DocumentViewerState {} diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart index e6335de..e3174f1 100644 --- a/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:bloc/bloc.dart'; import 'editor_session_event.dart'; import 'editor_session_state.dart'; @@ -5,6 +6,7 @@ import '../../services/file_service.dart'; class EditorSessionBloc extends Bloc { final FileService _fileService; + Timer? _expiryTimer; EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) { on(_onEditorSessionStarted); @@ -28,6 +30,12 @@ class EditorSessionBloc extends Bloc { } else { emit(EditorSessionReadOnly(viewUrl: session.editUrl)); } + if (session.expiresAt.isAfter(DateTime.now())) { + _expiryTimer = Timer( + session.expiresAt.difference(DateTime.now()), + () => emit(EditorSessionExpired()), + ); + } } catch (e) { emit(EditorSessionFailed(message: e.toString())); } @@ -51,6 +59,7 @@ class EditorSessionBloc extends Bloc { EditorSessionEnded event, Emitter emit, ) { + _expiryTimer?.cancel(); emit(EditorSessionInitial()); } } diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart index 79f65ba..29a37a3 100644 --- a/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_state.dart @@ -37,3 +37,5 @@ class EditorSessionFailed extends EditorSessionState { @override List get props => [message]; } + +class EditorSessionExpired extends EditorSessionState {} diff --git a/b0esche_cloud/lib/models/file_meta.dart b/b0esche_cloud/lib/models/file_meta.dart new file mode 100644 index 0000000..5d0fc0a --- /dev/null +++ b/b0esche_cloud/lib/models/file_meta.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class FileMeta extends Equatable { + final DateTime lastModified; + final String lastModifiedBy; + final int versionCount; + + const FileMeta({ + required this.lastModified, + required this.lastModifiedBy, + required this.versionCount, + }); + + @override + List get props => [lastModified, lastModifiedBy, versionCount]; + + factory FileMeta.fromJson(Map json) { + return FileMeta( + lastModified: DateTime.parse(json['lastModified']), + lastModifiedBy: json['lastModifiedBy'], + versionCount: json['versionCount'], + ); + } +} diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index 5a45fa3..e73457a 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -36,6 +36,26 @@ class _DocumentViewerState extends State { child: Scaffold( appBar: AppBar( title: const Text('Document Viewer'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(30), + child: BlocBuilder( + builder: (context, state) { + if (state is DocumentViewerReady) { + // Placeholder for meta + return Container( + height: 30, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Last modified: Unknown by Unknown (v1)', + style: const TextStyle(fontSize: 12), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), actions: [ BlocBuilder( builder: (context, state) { @@ -79,6 +99,29 @@ class _DocumentViewerState extends State { if (state is DocumentViewerError) { return Center(child: Text('Error: ${state.message}')); } + if (state is DocumentViewerSessionExpired) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Your viewing session expired. Click to reopen.', + ), + ElevatedButton( + onPressed: () { + _viewerBloc.add( + DocumentOpened( + orgId: widget.orgId, + fileId: widget.fileId, + ), + ); + }, + child: const Text('Reload'), + ), + ], + ), + ); + } if (state is DocumentViewerReady) { if (state.caps.isPdf) { // Use PDF viewer diff --git a/b0esche_cloud/lib/pages/editor_page.dart b/b0esche_cloud/lib/pages/editor_page.dart index e515df1..8253a0b 100644 --- a/b0esche_cloud/lib/pages/editor_page.dart +++ b/b0esche_cloud/lib/pages/editor_page.dart @@ -82,6 +82,27 @@ class _EditorPageState extends State { ), ); } + if (state is EditorSessionExpired) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Editing session expired.'), + ElevatedButton( + onPressed: () { + _editorBloc.add( + EditorSessionStarted( + orgId: widget.orgId, + fileId: widget.fileId, + ), + ); + }, + child: const Text('Reopen'), + ), + ], + ), + ); + } return const Center(child: Text('Editor not started')); }, ), diff --git a/b0esche_cloud/lib/services/document_api.dart b/b0esche_cloud/lib/services/document_api.dart index c25be3e..6a38e9d 100644 --- a/b0esche_cloud/lib/services/document_api.dart +++ b/b0esche_cloud/lib/services/document_api.dart @@ -1,6 +1,7 @@ import '../models/viewer_session.dart'; import '../models/editor_session.dart'; import '../models/annotation_payload.dart'; +import '../models/file_meta.dart'; import 'api_client.dart'; class DocumentApi { @@ -39,4 +40,11 @@ class DocumentApi { fromJson: (data) => null, ); } + + Future getFileMeta(String orgId, String fileId) async { + return await _apiClient.get( + '/orgs/$orgId/files/$fileId/meta', + fromJson: (data) => FileMeta.fromJson(data), + ); + } } diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 60282bb..15d226a 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -2,7 +2,6 @@ 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 { diff --git a/b0esche_cloud/lib/widgets/collabora_frame.dart b/b0esche_cloud/lib/widgets/collabora_frame.dart new file mode 100644 index 0000000..2f50ec9 --- /dev/null +++ b/b0esche_cloud/lib/widgets/collabora_frame.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; + +class CollaboraFrame extends StatefulWidget { + final Uri editUrl; + + const CollaboraFrame({super.key, required this.editUrl}); + + @override + State createState() => _CollaboraFrameState(); +} + +class _CollaboraFrameState extends State { + @override + Widget build(BuildContext context) { + if (kIsWeb) { + return HtmlElementView( + viewType: 'collabora-iframe', + onPlatformViewCreated: (int viewId) { + // On Web, create iframe + // For simplicity, placeholder + }, + ); + } else { + return Container( + color: Colors.grey, + child: const Center( + child: Text('Collabora iframe not supported on this platform'), + ), + ); + } + } +} diff --git a/b0esche_cloud/test/document_viewer_bloc_test.dart b/b0esche_cloud/test/document_viewer_bloc_test.dart index c6fdf4d..d877fef 100644 --- a/b0esche_cloud/test/document_viewer_bloc_test.dart +++ b/b0esche_cloud/test/document_viewer_bloc_test.dart @@ -9,38 +9,62 @@ 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 {} +class MockFileService extends Mock implements FileService { + Future? _viewerResponse; + + void setViewerResponse(Future response) { + _viewerResponse = response; + } + + void resetMock() { + _viewerResponse = null; + } + + @override + Future requestViewerSession(String orgId, String fileId) { + return _viewerResponse ?? super.noSuchMethod( + Invocation.method(#requestViewerSession, [orgId, fileId]), + returnValue: Future.value(null), + ); + } +} + + @override + Future requestViewerSession(String orgId, String fileId) { + return _viewerResponse ?? + super.noSuchMethod( + Invocation.method(#requestViewerSession, [orgId, fileId]), + returnValue: Future.value(null), + ); + } +} void main() { - late DocumentViewerBloc bloc; late MockFileService mockFileService; setUp(() { mockFileService = MockFileService(); - bloc = DocumentViewerBloc(mockFileService); }); tearDown(() { - bloc.close(); + reset(mockFileService); + mockFileService.resetMock(); }); group('DocumentViewerBloc', () { blocTest( - 'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds', + 'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails', build: () { - when(mockFileService.requestViewerSession('org1', 'file1')).thenAnswer( - (_) async => ViewerSession( - viewUrl: Uri.parse('https://example.com/view'), - capabilities: DocumentCapabilities( - canEdit: true, - canAnnotate: false, - isPdf: false, + mockFileService.setViewerResponse( + Future.error( + ApiError( + code: 'server_error', + message: 'Server error', + status: 500, ), - token: 'mock-token', - expiresAt: DateTime.now().add(const Duration(minutes: 30)), ), ); - return bloc; + return DocumentViewerBloc(mockFileService); }, act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), expect: () => [ @@ -57,12 +81,23 @@ void main() { ); blocTest( - 'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails', + 'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds', build: () { - when(mockFileService.requestViewerSession('org1', 'file1')).thenThrow( - ApiError(code: 'server_error', message: 'Server error', status: 500), + mockFileService.setViewerResponse( + Future.value( + 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; + return DocumentViewerBloc(mockFileService); }, act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), expect: () => [ @@ -73,22 +108,7 @@ void main() { 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, + build: () => DocumentViewerBloc(mockFileService), act: (bloc) => bloc.add(DocumentClosed()), expect: () => [DocumentViewerInitial()], );