docs first commit
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,5 @@ class DocumentViewerError extends DocumentViewerState {
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
|
||||
class DocumentViewerSessionExpired extends DocumentViewerState {}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,5 @@ class EditorSessionFailed extends EditorSessionState {
|
||||
@override
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -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'));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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/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()],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user