good morning
This commit is contained in:
@@ -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<DocumentViewerEvent, DocumentViewerState> {
|
||||
DocumentViewerBloc() : super(ViewerInitial()) {
|
||||
on<OpenDocument>(_onOpenDocument);
|
||||
on<CloseDocument>(_onCloseDocument);
|
||||
on<ReloadDocument>(_onReloadDocument);
|
||||
final FileService _fileService;
|
||||
|
||||
DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) {
|
||||
on<DocumentOpened>(_onDocumentOpened);
|
||||
on<DocumentReloaded>(_onDocumentReloaded);
|
||||
on<DocumentClosed>(_onDocumentClosed);
|
||||
}
|
||||
|
||||
void _onOpenDocument(
|
||||
OpenDocument event,
|
||||
void _onDocumentOpened(
|
||||
DocumentOpened event,
|
||||
Emitter<DocumentViewerState> 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<DocumentViewerState> emit,
|
||||
) {
|
||||
emit(ViewerInitial());
|
||||
}
|
||||
|
||||
void _onReloadDocument(
|
||||
ReloadDocument event,
|
||||
Emitter<DocumentViewerState> 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<DocumentViewerState> 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<DocumentViewerState> emit,
|
||||
) {
|
||||
emit(DocumentViewerInitial());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@ abstract class DocumentViewerEvent extends Equatable {
|
||||
List<Object> 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<Object> get props => [fileId, orgId];
|
||||
List<Object> get props => [orgId, fileId];
|
||||
}
|
||||
|
||||
class CloseDocument extends DocumentViewerEvent {}
|
||||
class DocumentReloaded extends DocumentViewerEvent {}
|
||||
|
||||
class ReloadDocument extends DocumentViewerEvent {}
|
||||
class DocumentClosed extends DocumentViewerEvent {}
|
||||
|
||||
@@ -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<Object> 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<Object> get props => [viewUrl, canEdit, fileName];
|
||||
List<Object> 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<Object> get props => [error];
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
|
||||
@@ -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<EditorSessionEvent, EditorSessionState> {
|
||||
final FileService _fileService;
|
||||
|
||||
EditorSessionBloc(this._fileService) : super(EditorSessionInitial()) {
|
||||
on<EditorSessionStarted>(_onEditorSessionStarted);
|
||||
on<EditorSessionConnected>(_onEditorSessionConnected);
|
||||
on<EditorSessionDisconnected>(_onEditorSessionDisconnected);
|
||||
on<EditorSessionEnded>(_onEditorSessionEnded);
|
||||
}
|
||||
|
||||
void _onEditorSessionStarted(
|
||||
EditorSessionStarted event,
|
||||
Emitter<EditorSessionState> 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<EditorSessionState> emit,
|
||||
) {
|
||||
// Handle connection if needed
|
||||
}
|
||||
|
||||
void _onEditorSessionDisconnected(
|
||||
EditorSessionDisconnected event,
|
||||
Emitter<EditorSessionState> emit,
|
||||
) {
|
||||
// Handle disconnection if needed
|
||||
}
|
||||
|
||||
void _onEditorSessionEnded(
|
||||
EditorSessionEnded event,
|
||||
Emitter<EditorSessionState> emit,
|
||||
) {
|
||||
emit(EditorSessionInitial());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class EditorSessionEvent extends Equatable {
|
||||
const EditorSessionEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class EditorSessionStarted extends EditorSessionEvent {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
const EditorSessionStarted({required this.orgId, required this.fileId});
|
||||
|
||||
@override
|
||||
List<Object> get props => [orgId, fileId];
|
||||
}
|
||||
|
||||
class EditorSessionConnected extends EditorSessionEvent {}
|
||||
|
||||
class EditorSessionDisconnected extends EditorSessionEvent {}
|
||||
|
||||
class EditorSessionEnded extends EditorSessionEvent {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class EditorSessionState extends Equatable {
|
||||
const EditorSessionState();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class EditorSessionInitial extends EditorSessionState {}
|
||||
|
||||
class EditorSessionStarting extends EditorSessionState {}
|
||||
|
||||
class EditorSessionActive extends EditorSessionState {
|
||||
final Uri editUrl;
|
||||
|
||||
const EditorSessionActive({required this.editUrl});
|
||||
|
||||
@override
|
||||
List<Object> get props => [editUrl];
|
||||
}
|
||||
|
||||
class EditorSessionReadOnly extends EditorSessionState {
|
||||
final Uri viewUrl;
|
||||
|
||||
const EditorSessionReadOnly({required this.viewUrl});
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewUrl];
|
||||
}
|
||||
|
||||
class EditorSessionFailed extends EditorSessionState {
|
||||
final String message;
|
||||
|
||||
const EditorSessionFailed({required this.message});
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
113
b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart
Normal file
113
b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart
Normal file
@@ -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<PdfAnnotationEvent, PdfAnnotationState> {
|
||||
final FileService _fileService;
|
||||
final String _orgId;
|
||||
final String _fileId;
|
||||
final List<Annotation> _annotations = <Annotation>[];
|
||||
final List<Annotation> _undoStack = <Annotation>[];
|
||||
String _selectedTool = 'text';
|
||||
|
||||
PdfAnnotationBloc(this._fileService, this._orgId, this._fileId)
|
||||
: super(PdfAnnotationIdle()) {
|
||||
on<AnnotationToolSelected>(_onAnnotationToolSelected);
|
||||
on<AnnotationAdded>(_onAnnotationAdded);
|
||||
on<AnnotationRemoved>(_onAnnotationRemoved);
|
||||
on<AnnotationUndo>(_onAnnotationUndo);
|
||||
on<AnnotationRedo>(_onAnnotationRedo);
|
||||
on<AnnotationsSaved>(_onAnnotationsSaved);
|
||||
}
|
||||
|
||||
void _onAnnotationToolSelected(
|
||||
AnnotationToolSelected event,
|
||||
Emitter<PdfAnnotationState> emit,
|
||||
) {
|
||||
_selectedTool = event.tool;
|
||||
emit(
|
||||
PdfAnnotationEditing(
|
||||
annotations: _annotations,
|
||||
undoStack: _undoStack,
|
||||
selectedTool: _selectedTool,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnnotationAdded(
|
||||
AnnotationAdded event,
|
||||
Emitter<PdfAnnotationState> emit,
|
||||
) {
|
||||
_annotations.add(event.annotation);
|
||||
emit(
|
||||
PdfAnnotationEditing(
|
||||
annotations: _annotations,
|
||||
undoStack: _undoStack,
|
||||
selectedTool: _selectedTool,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnnotationRemoved(
|
||||
AnnotationRemoved event,
|
||||
Emitter<PdfAnnotationState> emit,
|
||||
) {
|
||||
_annotations.removeWhere((a) => a.id == event.id);
|
||||
emit(
|
||||
PdfAnnotationEditing(
|
||||
annotations: _annotations,
|
||||
undoStack: _undoStack,
|
||||
selectedTool: _selectedTool,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnnotationUndo(
|
||||
AnnotationUndo event,
|
||||
Emitter<PdfAnnotationState> emit,
|
||||
) {
|
||||
if (_annotations.isNotEmpty) {
|
||||
_undoStack.add(_annotations.removeLast());
|
||||
}
|
||||
emit(
|
||||
PdfAnnotationEditing(
|
||||
annotations: _annotations,
|
||||
undoStack: _undoStack,
|
||||
selectedTool: _selectedTool,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnnotationRedo(
|
||||
AnnotationRedo event,
|
||||
Emitter<PdfAnnotationState> emit,
|
||||
) {
|
||||
if (_undoStack.isNotEmpty) {
|
||||
_annotations.add(_undoStack.removeLast());
|
||||
}
|
||||
emit(
|
||||
PdfAnnotationEditing(
|
||||
annotations: _annotations,
|
||||
undoStack: _undoStack,
|
||||
selectedTool: _selectedTool,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnnotationsSaved(
|
||||
AnnotationsSaved event,
|
||||
Emitter<PdfAnnotationState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../models/annotation.dart';
|
||||
|
||||
abstract class PdfAnnotationEvent extends Equatable {
|
||||
const PdfAnnotationEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class AnnotationToolSelected extends PdfAnnotationEvent {
|
||||
final String tool; // e.g. 'text', 'highlight', 'signature'
|
||||
|
||||
const AnnotationToolSelected({required this.tool});
|
||||
|
||||
@override
|
||||
List<Object> get props => [tool];
|
||||
}
|
||||
|
||||
class AnnotationAdded extends PdfAnnotationEvent {
|
||||
final Annotation annotation;
|
||||
|
||||
const AnnotationAdded({required this.annotation});
|
||||
|
||||
@override
|
||||
List<Object> get props => [annotation];
|
||||
}
|
||||
|
||||
class AnnotationRemoved extends PdfAnnotationEvent {
|
||||
final String id;
|
||||
|
||||
const AnnotationRemoved({required this.id});
|
||||
|
||||
@override
|
||||
List<Object> get props => [id];
|
||||
}
|
||||
|
||||
class AnnotationUndo extends PdfAnnotationEvent {}
|
||||
|
||||
class AnnotationRedo extends PdfAnnotationEvent {}
|
||||
|
||||
class AnnotationsSaved extends PdfAnnotationEvent {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../models/annotation.dart';
|
||||
|
||||
abstract class PdfAnnotationState extends Equatable {
|
||||
const PdfAnnotationState();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class PdfAnnotationIdle extends PdfAnnotationState {}
|
||||
|
||||
class PdfAnnotationEditing extends PdfAnnotationState {
|
||||
final List<Annotation> annotations;
|
||||
final List<Annotation> undoStack;
|
||||
final String selectedTool;
|
||||
|
||||
const PdfAnnotationEditing({
|
||||
required this.annotations,
|
||||
required this.undoStack,
|
||||
required this.selectedTool,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> 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<Object> get props => [message];
|
||||
}
|
||||
@@ -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']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
106
b0esche_cloud/lib/models/annotation.dart
Normal file
106
b0esche_cloud/lib/models/annotation.dart
Normal file
@@ -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<Object?> get props => [id, pageIndex];
|
||||
|
||||
Map<String, dynamic> 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<Object?> get props => super.props + [text, x, y, width, height];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': 'text',
|
||||
'id': id,
|
||||
'pageIndex': pageIndex,
|
||||
'text': text,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class HighlightAnnotation extends Annotation {
|
||||
final List<double> points; // List of x,y coordinates
|
||||
|
||||
const HighlightAnnotation({
|
||||
required super.id,
|
||||
required super.pageIndex,
|
||||
required this.points,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => super.props + [points];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> 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<Object?> get props => super.props + [imagePath, x, y, width, height];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': 'signature',
|
||||
'id': id,
|
||||
'pageIndex': pageIndex,
|
||||
'imagePath': imagePath,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
b0esche_cloud/lib/models/annotation_payload.dart
Normal file
22
b0esche_cloud/lib/models/annotation_payload.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'annotation.dart';
|
||||
|
||||
class AnnotationPayload extends Equatable {
|
||||
final List<Annotation> annotations;
|
||||
final String baseVersionId;
|
||||
|
||||
const AnnotationPayload({
|
||||
required this.annotations,
|
||||
required this.baseVersionId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annotations, baseVersionId];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'annotations': annotations.map((a) => a.toJson()).toList(),
|
||||
'baseVersionId': baseVersionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
12
b0esche_cloud/lib/models/api_error.dart
Normal file
12
b0esche_cloud/lib/models/api_error.dart
Normal file
@@ -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<Object?> get props => [code, message, status];
|
||||
}
|
||||
24
b0esche_cloud/lib/models/document_capabilities.dart
Normal file
24
b0esche_cloud/lib/models/document_capabilities.dart
Normal file
@@ -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<Object?> get props => [canEdit, canAnnotate, isPdf];
|
||||
|
||||
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentCapabilities(
|
||||
canEdit: json['canEdit'],
|
||||
canAnnotate: json['canAnnotate'],
|
||||
isPdf: json['isPdf'],
|
||||
);
|
||||
}
|
||||
}
|
||||
24
b0esche_cloud/lib/models/editor_session.dart
Normal file
24
b0esche_cloud/lib/models/editor_session.dart
Normal file
@@ -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<Object?> get props => [editUrl, readOnly, expiresAt];
|
||||
|
||||
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
||||
return EditorSession(
|
||||
editUrl: Uri.parse(json['editUrl']),
|
||||
readOnly: json['readOnly'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
b0esche_cloud/lib/models/viewer_session.dart
Normal file
28
b0esche_cloud/lib/models/viewer_session.dart
Normal file
@@ -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<Object?> get props => [viewUrl, capabilities, token, expiresAt];
|
||||
|
||||
factory ViewerSession.fromJson(Map<String, dynamic> json) {
|
||||
return ViewerSession(
|
||||
viewUrl: Uri.parse(json['viewUrl']),
|
||||
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
|
||||
token: json['token'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentViewer> createState() => _DocumentViewerState();
|
||||
@@ -20,10 +25,8 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewerBloc = DocumentViewerBloc();
|
||||
_viewerBloc.add(
|
||||
OpenDocument(fileId: widget.fileId, orgId: 'org1'),
|
||||
); // Assume org1
|
||||
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -32,21 +35,29 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
value: _viewerBloc,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is ViewerReady) {
|
||||
return Text(state.fileName);
|
||||
}
|
||||
return const Text('Document Viewer');
|
||||
},
|
||||
),
|
||||
title: const Text('Document Viewer'),
|
||||
actions: [
|
||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
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<DocumentViewer> {
|
||||
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<DocumentViewer> {
|
||||
),
|
||||
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
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'));
|
||||
},
|
||||
|
||||
97
b0esche_cloud/lib/pages/editor_page.dart
Normal file
97
b0esche_cloud/lib/pages/editor_page.dart
Normal file
@@ -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<EditorPage> createState() => _EditorPageState();
|
||||
}
|
||||
|
||||
class _EditorPageState extends State<EditorPage> {
|
||||
late EditorSessionBloc _editorBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||
_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<EditorSessionBloc, EditorSessionState>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<FileExplorer> createState() => _FileExplorerState();
|
||||
@@ -47,11 +49,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Assume org1 for now
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(orgId: 'org1', path: '/'),
|
||||
LoadDirectory(orgId: widget.orgId, path: '/'),
|
||||
);
|
||||
context.read<PermissionBloc>().add(LoadPermissions('org1'));
|
||||
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
|
||||
}
|
||||
|
||||
Future<String?> _showCreateFolderDialog(BuildContext context) async {
|
||||
@@ -529,7 +530,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
if (file.type == FileType.folder) {
|
||||
context.read<FileBrowserBloc>().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(
|
||||
|
||||
@@ -66,7 +66,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
child: isLoggedIn
|
||||
? const FileExplorer()
|
||||
? const FileExplorer(orgId: 'org1')
|
||||
: const LoginForm(),
|
||||
),
|
||||
// Top-left radial glow - primary accent light
|
||||
|
||||
@@ -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<List<FileItem>> getFiles(String orgId, String path);
|
||||
@@ -9,4 +12,11 @@ abstract class FileRepository {
|
||||
Future<void> moveFile(String orgId, String sourcePath, String targetPath);
|
||||
Future<void> renameFile(String orgId, String path, String newName);
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query);
|
||||
Future<ViewerSession> requestViewerSession(String orgId, String fileId);
|
||||
Future<EditorSession> requestEditorSession(String orgId, String fileId);
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> uploadFile(String orgId, FileItem file) async {
|
||||
Future<EditorSession> 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<void> uploadFile(String orgId, FileItem file) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
_files.add(file);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> 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<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// Mock: just delay, assume success
|
||||
}
|
||||
}
|
||||
|
||||
140
b0esche_cloud/lib/services/api_client.dart
Normal file
140
b0esche_cloud/lib/services/api_client.dart
Normal file
@@ -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<bool> _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<T> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? 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<T> post<T>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
b0esche_cloud/lib/services/document_api.dart
Normal file
42
b0esche_cloud/lib/services/document_api.dart
Normal file
@@ -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<ViewerSession> requestViewerSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _apiClient.get(
|
||||
'/orgs/$orgId/files/$fileId/view',
|
||||
fromJson: (data) => ViewerSession.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<EditorSession> requestEditorSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _apiClient.get(
|
||||
'/orgs/$orgId/files/$fileId/edit',
|
||||
fromJson: (data) => EditorSession.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
AnnotationPayload payload,
|
||||
) async {
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files/$fileId/annotations',
|
||||
data: payload.toJson(),
|
||||
fromJson: (data) => null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ViewerSession> 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<EditorSession> 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<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
throw Exception('OrgId and fileId cannot be empty');
|
||||
}
|
||||
await _fileRepository.saveAnnotations(orgId, fileId, annotations);
|
||||
}
|
||||
}
|
||||
|
||||
96
b0esche_cloud/test/document_viewer_bloc_test.dart
Normal file
96
b0esche_cloud/test/document_viewer_bloc_test.dart
Normal file
@@ -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<DocumentViewerBloc, DocumentViewerState>(
|
||||
'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<DocumentViewerBloc, DocumentViewerState>(
|
||||
'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<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,
|
||||
act: (bloc) => bloc.add(DocumentClosed()),
|
||||
expect: () => [DocumentViewerInitial()],
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user