docs first commit

This commit is contained in:
Leon Bösche
2025-12-17 14:53:29 +01:00
parent dd1aa4775c
commit 5b71c6b9d2
11 changed files with 219 additions and 41 deletions

View File

@@ -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<DocumentViewerEvent, DocumentViewerState> {
final FileService _fileService;
Timer? _expiryTimer;
DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) {
on<DocumentOpened>(_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<DocumentViewerState> emit,
) {
_expiryTimer?.cancel();
emit(DocumentViewerInitial());
}
}

View File

@@ -30,3 +30,5 @@ class DocumentViewerError extends DocumentViewerState {
@override
List<Object> get props => [message];
}
class DocumentViewerSessionExpired extends DocumentViewerState {}

View File

@@ -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<EditorSessionEvent, EditorSessionState> {
final FileService _fileService;
Timer? _expiryTimer;
EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) {
on<EditorSessionStarted>(_onEditorSessionStarted);
@@ -28,6 +30,12 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
} 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<EditorSessionEvent, EditorSessionState> {
EditorSessionEnded event,
Emitter<EditorSessionState> emit,
) {
_expiryTimer?.cancel();
emit(EditorSessionInitial());
}
}

View File

@@ -37,3 +37,5 @@ class EditorSessionFailed extends EditorSessionState {
@override
List<Object> get props => [message];
}
class EditorSessionExpired extends EditorSessionState {}

View File

@@ -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<Object?> get props => [lastModified, lastModifiedBy, versionCount];
factory FileMeta.fromJson(Map<String, dynamic> json) {
return FileMeta(
lastModified: DateTime.parse(json['lastModified']),
lastModifiedBy: json['lastModifiedBy'],
versionCount: json['versionCount'],
);
}
}

View File

@@ -36,6 +36,26 @@ class _DocumentViewerState extends State<DocumentViewer> {
child: Scaffold(
appBar: AppBar(
title: const Text('Document Viewer'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(30),
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
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<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
@@ -79,6 +99,29 @@ class _DocumentViewerState extends State<DocumentViewer> {
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

View File

@@ -82,6 +82,27 @@ class _EditorPageState extends State<EditorPage> {
),
);
}
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'));
},
),

View File

@@ -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<FileMeta> getFileMeta(String orgId, String fileId) async {
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/meta',
fromJson: (data) => FileMeta.fromJson(data),
);
}
}

View File

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

View File

@@ -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<CollaboraFrame> createState() => _CollaboraFrameState();
}
class _CollaboraFrameState extends State<CollaboraFrame> {
@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'),
),
);
}
}
}

View File

@@ -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<ViewerSession>? _viewerResponse;
void setViewerResponse(Future<ViewerSession> response) {
_viewerResponse = response;
}
void resetMock() {
_viewerResponse = null;
}
@override
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
return _viewerResponse ?? super.noSuchMethod(
Invocation.method(#requestViewerSession, [orgId, fileId]),
returnValue: Future.value(null),
);
}
}
@override
Future<ViewerSession> 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<DocumentViewerBloc, DocumentViewerState>(
'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<DocumentViewerBloc, DocumentViewerState>(
'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<DocumentViewerBloc, DocumentViewerState>(
'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<DocumentViewerBloc, DocumentViewerState>(
'emits [DocumentViewerInitial] when DocumentClosed',
build: () => bloc,
build: () => DocumentViewerBloc(mockFileService),
act: (bloc) => bloc.add(DocumentClosed()),
expect: () => [DocumentViewerInitial()],
);