docs first commit
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'document_viewer_event.dart';
|
import 'document_viewer_event.dart';
|
||||||
import 'document_viewer_state.dart';
|
import 'document_viewer_state.dart';
|
||||||
@@ -7,6 +8,7 @@ import '../../models/api_error.dart';
|
|||||||
class DocumentViewerBloc
|
class DocumentViewerBloc
|
||||||
extends Bloc<DocumentViewerEvent, DocumentViewerState> {
|
extends Bloc<DocumentViewerEvent, DocumentViewerState> {
|
||||||
final FileService _fileService;
|
final FileService _fileService;
|
||||||
|
Timer? _expiryTimer;
|
||||||
|
|
||||||
DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) {
|
DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) {
|
||||||
on<DocumentOpened>(_onDocumentOpened);
|
on<DocumentOpened>(_onDocumentOpened);
|
||||||
@@ -30,6 +32,12 @@ class DocumentViewerBloc
|
|||||||
caps: session.capabilities,
|
caps: session.capabilities,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (session.expiresAt.isAfter(DateTime.now())) {
|
||||||
|
_expiryTimer = Timer(
|
||||||
|
session.expiresAt.difference(DateTime.now()),
|
||||||
|
() => emit(DocumentViewerSessionExpired()),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is ApiError) {
|
if (e is ApiError) {
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
@@ -39,7 +47,14 @@ class DocumentViewerBloc
|
|||||||
case 'permission_denied':
|
case 'permission_denied':
|
||||||
emit(
|
emit(
|
||||||
DocumentViewerError(
|
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;
|
break;
|
||||||
@@ -65,14 +80,15 @@ class DocumentViewerBloc
|
|||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is DocumentViewerReady) {
|
if (currentState is DocumentViewerReady) {
|
||||||
|
_expiryTimer?.cancel();
|
||||||
emit(DocumentViewerLoading());
|
emit(DocumentViewerLoading());
|
||||||
try {
|
try {
|
||||||
// Assume orgId and fileId are stored, but for simplicity, reload current
|
// Re-fetch session
|
||||||
// In real app, store last orgId and fileId
|
// For simplicity, re-emit (in real, re-call the service)
|
||||||
// For now, just re-emit ready (mock)
|
|
||||||
emit(currentState);
|
emit(currentState);
|
||||||
|
// Restart timer if needed
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DocumentViewerError(message: e.toString()));
|
emit(DocumentViewerError(message: 'Unexpected error: ${e.toString()}'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +97,7 @@ class DocumentViewerBloc
|
|||||||
DocumentClosed event,
|
DocumentClosed event,
|
||||||
Emitter<DocumentViewerState> emit,
|
Emitter<DocumentViewerState> emit,
|
||||||
) {
|
) {
|
||||||
|
_expiryTimer?.cancel();
|
||||||
emit(DocumentViewerInitial());
|
emit(DocumentViewerInitial());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ class DocumentViewerError extends DocumentViewerState {
|
|||||||
@override
|
@override
|
||||||
List<Object> get props => [message];
|
List<Object> get props => [message];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DocumentViewerSessionExpired extends DocumentViewerState {}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'editor_session_event.dart';
|
import 'editor_session_event.dart';
|
||||||
import 'editor_session_state.dart';
|
import 'editor_session_state.dart';
|
||||||
@@ -5,6 +6,7 @@ import '../../services/file_service.dart';
|
|||||||
|
|
||||||
class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
||||||
final FileService _fileService;
|
final FileService _fileService;
|
||||||
|
Timer? _expiryTimer;
|
||||||
|
|
||||||
EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) {
|
EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) {
|
||||||
on<EditorSessionStarted>(_onEditorSessionStarted);
|
on<EditorSessionStarted>(_onEditorSessionStarted);
|
||||||
@@ -28,6 +30,12 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
} else {
|
} else {
|
||||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
||||||
}
|
}
|
||||||
|
if (session.expiresAt.isAfter(DateTime.now())) {
|
||||||
|
_expiryTimer = Timer(
|
||||||
|
session.expiresAt.difference(DateTime.now()),
|
||||||
|
() => emit(EditorSessionExpired()),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(EditorSessionFailed(message: e.toString()));
|
emit(EditorSessionFailed(message: e.toString()));
|
||||||
}
|
}
|
||||||
@@ -51,6 +59,7 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
EditorSessionEnded event,
|
EditorSessionEnded event,
|
||||||
Emitter<EditorSessionState> emit,
|
Emitter<EditorSessionState> emit,
|
||||||
) {
|
) {
|
||||||
|
_expiryTimer?.cancel();
|
||||||
emit(EditorSessionInitial());
|
emit(EditorSessionInitial());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ class EditorSessionFailed extends EditorSessionState {
|
|||||||
@override
|
@override
|
||||||
List<Object> get props => [message];
|
List<Object> get props => [message];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class EditorSessionExpired extends EditorSessionState {}
|
||||||
|
|||||||
24
b0esche_cloud/lib/models/file_meta.dart
Normal file
24
b0esche_cloud/lib/models/file_meta.dart
Normal 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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,26 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Document Viewer'),
|
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: [
|
actions: [
|
||||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -79,6 +99,29 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
if (state is DocumentViewerError) {
|
if (state is DocumentViewerError) {
|
||||||
return Center(child: Text('Error: ${state.message}'));
|
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 is DocumentViewerReady) {
|
||||||
if (state.caps.isPdf) {
|
if (state.caps.isPdf) {
|
||||||
// Use PDF viewer
|
// Use PDF viewer
|
||||||
|
|||||||
@@ -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'));
|
return const Center(child: Text('Editor not started'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../models/viewer_session.dart';
|
import '../models/viewer_session.dart';
|
||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation_payload.dart';
|
import '../models/annotation_payload.dart';
|
||||||
|
import '../models/file_meta.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
class DocumentApi {
|
class DocumentApi {
|
||||||
@@ -39,4 +40,11 @@ class DocumentApi {
|
|||||||
fromJson: (data) => null,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import '../models/file_item.dart';
|
|||||||
import '../models/viewer_session.dart';
|
import '../models/viewer_session.dart';
|
||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import '../models/api_error.dart';
|
|
||||||
import '../repositories/file_repository.dart';
|
import '../repositories/file_repository.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
|
|||||||
33
b0esche_cloud/lib/widgets/collabora_frame.dart
Normal file
33
b0esche_cloud/lib/widgets/collabora_frame.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/document_capabilities.dart';
|
||||||
import 'package:b0esche_cloud/models/api_error.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() {
|
void main() {
|
||||||
late DocumentViewerBloc bloc;
|
|
||||||
late MockFileService mockFileService;
|
late MockFileService mockFileService;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockFileService = MockFileService();
|
mockFileService = MockFileService();
|
||||||
bloc = DocumentViewerBloc(mockFileService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
bloc.close();
|
reset(mockFileService);
|
||||||
|
mockFileService.resetMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
group('DocumentViewerBloc', () {
|
group('DocumentViewerBloc', () {
|
||||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||||
'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds',
|
'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails',
|
||||||
build: () {
|
build: () {
|
||||||
when(mockFileService.requestViewerSession('org1', 'file1')).thenAnswer(
|
mockFileService.setViewerResponse(
|
||||||
(_) async => ViewerSession(
|
Future.error(
|
||||||
viewUrl: Uri.parse('https://example.com/view'),
|
ApiError(
|
||||||
capabilities: DocumentCapabilities(
|
code: 'server_error',
|
||||||
canEdit: true,
|
message: 'Server error',
|
||||||
canAnnotate: false,
|
status: 500,
|
||||||
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')),
|
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||||
expect: () => [
|
expect: () => [
|
||||||
@@ -57,12 +81,23 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||||
'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails',
|
'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds',
|
||||||
build: () {
|
build: () {
|
||||||
when(mockFileService.requestViewerSession('org1', 'file1')).thenThrow(
|
mockFileService.setViewerResponse(
|
||||||
ApiError(code: 'server_error', message: 'Server error', status: 500),
|
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')),
|
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||||
expect: () => [
|
expect: () => [
|
||||||
@@ -73,22 +108,7 @@ void main() {
|
|||||||
|
|
||||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||||
'emits [DocumentViewerInitial] when DocumentClosed',
|
'emits [DocumentViewerInitial] when DocumentClosed',
|
||||||
setUp: () {
|
build: () => DocumentViewerBloc(mockFileService),
|
||||||
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,
|
|
||||||
act: (bloc) => bloc.add(DocumentClosed()),
|
act: (bloc) => bloc.add(DocumentClosed()),
|
||||||
expect: () => [DocumentViewerInitial()],
|
expect: () => [DocumentViewerInitial()],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user