good morning
This commit is contained in:
@@ -1,48 +1,86 @@
|
|||||||
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';
|
||||||
|
import '../../services/file_service.dart';
|
||||||
|
import '../../models/api_error.dart';
|
||||||
|
|
||||||
class DocumentViewerBloc
|
class DocumentViewerBloc
|
||||||
extends Bloc<DocumentViewerEvent, DocumentViewerState> {
|
extends Bloc<DocumentViewerEvent, DocumentViewerState> {
|
||||||
DocumentViewerBloc() : super(ViewerInitial()) {
|
final FileService _fileService;
|
||||||
on<OpenDocument>(_onOpenDocument);
|
|
||||||
on<CloseDocument>(_onCloseDocument);
|
DocumentViewerBloc(this._fileService) : super(DocumentViewerInitial()) {
|
||||||
on<ReloadDocument>(_onReloadDocument);
|
on<DocumentOpened>(_onDocumentOpened);
|
||||||
|
on<DocumentReloaded>(_onDocumentReloaded);
|
||||||
|
on<DocumentClosed>(_onDocumentClosed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onOpenDocument(
|
void _onDocumentOpened(
|
||||||
OpenDocument event,
|
DocumentOpened event,
|
||||||
Emitter<DocumentViewerState> emit,
|
Emitter<DocumentViewerState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(ViewerLoading());
|
emit(DocumentViewerLoading());
|
||||||
// Simulate requesting viewer session from backend
|
try {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
final session = await _fileService.requestViewerSession(
|
||||||
// Mock: assume PDF viewer
|
event.orgId,
|
||||||
final viewUrl = 'https://storage.b0esche.cloud/view/${event.fileId}';
|
event.fileId,
|
||||||
final canEdit = false; // Based on permissions
|
);
|
||||||
final fileName = 'document.pdf';
|
emit(
|
||||||
emit(ViewerReady(viewUrl: viewUrl, canEdit: canEdit, fileName: fileName));
|
DocumentViewerReady(
|
||||||
}
|
viewUrl: session.viewUrl,
|
||||||
|
caps: session.capabilities,
|
||||||
void _onCloseDocument(
|
),
|
||||||
CloseDocument event,
|
);
|
||||||
Emitter<DocumentViewerState> emit,
|
} catch (e) {
|
||||||
) {
|
if (e is ApiError) {
|
||||||
emit(ViewerInitial());
|
switch (e.code) {
|
||||||
}
|
case 'unauthorized':
|
||||||
|
// Already handled by ApiClient
|
||||||
void _onReloadDocument(
|
break;
|
||||||
ReloadDocument event,
|
case 'permission_denied':
|
||||||
Emitter<DocumentViewerState> emit,
|
emit(
|
||||||
) {
|
DocumentViewerError(
|
||||||
// Reload current document
|
message: 'You do not have permission to view this document',
|
||||||
final currentState = state;
|
),
|
||||||
if (currentState is ViewerReady) {
|
);
|
||||||
emit(ViewerLoading());
|
break;
|
||||||
// Simulate reload
|
case 'not_found':
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
emit(DocumentViewerError(message: 'Document not found'));
|
||||||
emit(currentState);
|
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 => [];
|
List<Object> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenDocument extends DocumentViewerEvent {
|
class DocumentOpened extends DocumentViewerEvent {
|
||||||
final String fileId;
|
|
||||||
final String orgId;
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
|
||||||
const OpenDocument({required this.fileId, required this.orgId});
|
const DocumentOpened({required this.orgId, required this.fileId});
|
||||||
|
|
||||||
@override
|
@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 'package:equatable/equatable.dart';
|
||||||
|
import '../../models/document_capabilities.dart';
|
||||||
|
|
||||||
abstract class DocumentViewerState extends Equatable {
|
abstract class DocumentViewerState extends Equatable {
|
||||||
const DocumentViewerState();
|
const DocumentViewerState();
|
||||||
@@ -7,30 +8,25 @@ abstract class DocumentViewerState extends Equatable {
|
|||||||
List<Object> get props => [];
|
List<Object> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerInitial extends DocumentViewerState {}
|
class DocumentViewerInitial extends DocumentViewerState {}
|
||||||
|
|
||||||
class ViewerLoading extends DocumentViewerState {}
|
class DocumentViewerLoading extends DocumentViewerState {}
|
||||||
|
|
||||||
class ViewerReady extends DocumentViewerState {
|
class DocumentViewerReady extends DocumentViewerState {
|
||||||
final String viewUrl;
|
final Uri viewUrl;
|
||||||
final bool canEdit;
|
final DocumentCapabilities caps;
|
||||||
final String fileName;
|
|
||||||
|
|
||||||
const ViewerReady({
|
const DocumentViewerReady({required this.viewUrl, required this.caps});
|
||||||
required this.viewUrl,
|
|
||||||
required this.canEdit,
|
|
||||||
required this.fileName,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [viewUrl, canEdit, fileName];
|
List<Object> get props => [viewUrl, caps];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerError extends DocumentViewerState {
|
class DocumentViewerError extends DocumentViewerState {
|
||||||
final String error;
|
final String message;
|
||||||
|
|
||||||
const ViewerError(this.error);
|
const DocumentViewerError({required this.message});
|
||||||
|
|
||||||
@override
|
@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/login_form.dart';
|
||||||
import 'pages/file_explorer.dart';
|
import 'pages/file_explorer.dart';
|
||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
|
import 'pages/editor_page.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
final GoRouter _router = GoRouter(
|
final GoRouter _router = GoRouter(
|
||||||
@@ -21,13 +22,23 @@ final GoRouter _router = GoRouter(
|
|||||||
GoRoute(path: '/login', builder: (context, state) => const LoginForm()),
|
GoRoute(path: '/login', builder: (context, state) => const LoginForm()),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/viewer/:fileId',
|
path: '/viewer/:orgId/:fileId',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => DocumentViewer(
|
||||||
DocumentViewer(fileId: state.pathParameters['fileId']!),
|
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(
|
GoRoute(
|
||||||
path: '/org/:orgId/drive',
|
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_bloc.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_event.dart';
|
import '../blocs/document_viewer/document_viewer_event.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_state.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 {
|
class DocumentViewer extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|
||||||
const DocumentViewer({super.key, required this.fileId});
|
const DocumentViewer({super.key, required this.orgId, required this.fileId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DocumentViewer> createState() => _DocumentViewerState();
|
State<DocumentViewer> createState() => _DocumentViewerState();
|
||||||
@@ -20,10 +25,8 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_viewerBloc = DocumentViewerBloc();
|
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||||
_viewerBloc.add(
|
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||||
OpenDocument(fileId: widget.fileId, orgId: 'org1'),
|
|
||||||
); // Assume org1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,21 +35,29 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
value: _viewerBloc,
|
value: _viewerBloc,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
title: const Text('Document Viewer'),
|
||||||
builder: (context, state) {
|
|
||||||
if (state is ViewerReady) {
|
|
||||||
return Text(state.fileName);
|
|
||||||
}
|
|
||||||
return const Text('Document Viewer');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_viewerBloc.add(ReloadDocument());
|
_viewerBloc.add(DocumentReloaded());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -54,7 +65,7 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_viewerBloc.add(CloseDocument());
|
_viewerBloc.add(DocumentClosed());
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -62,23 +73,29 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
),
|
),
|
||||||
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is ViewerLoading) {
|
if (state is DocumentViewerLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (state is ViewerError) {
|
if (state is DocumentViewerError) {
|
||||||
return Center(child: Text('Error: ${state.error}'));
|
return Center(child: Text('Error: ${state.message}'));
|
||||||
}
|
}
|
||||||
if (state is ViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
return Container(
|
if (state.caps.isPdf) {
|
||||||
color: AppTheme.secondaryText,
|
// Use PDF viewer
|
||||||
child: Center(
|
return SfPdfViewer.network(state.viewUrl.toString());
|
||||||
child: Text(
|
} else {
|
||||||
'Document Viewer Placeholder\n(Backend URL: ${state.viewUrl})',
|
// Placeholder for office docs iframe
|
||||||
textAlign: TextAlign.center,
|
return Container(
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
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'));
|
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';
|
import '../theme/modern_glass_button.dart';
|
||||||
|
|
||||||
class FileExplorer extends StatefulWidget {
|
class FileExplorer extends StatefulWidget {
|
||||||
const FileExplorer({super.key});
|
final String orgId;
|
||||||
|
|
||||||
|
const FileExplorer({super.key, required this.orgId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FileExplorer> createState() => _FileExplorerState();
|
State<FileExplorer> createState() => _FileExplorerState();
|
||||||
@@ -47,11 +49,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Assume org1 for now
|
|
||||||
context.read<FileBrowserBloc>().add(
|
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 {
|
Future<String?> _showCreateFolderDialog(BuildContext context) async {
|
||||||
@@ -529,7 +530,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (file.type == FileType.folder) {
|
if (file.type == FileType.folder) {
|
||||||
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
||||||
} else {
|
} 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(
|
child: Container(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
Container(
|
Container(
|
||||||
decoration: AppTheme.glassDecoration,
|
decoration: AppTheme.glassDecoration,
|
||||||
child: isLoggedIn
|
child: isLoggedIn
|
||||||
? const FileExplorer()
|
? const FileExplorer(orgId: 'org1')
|
||||||
: const LoginForm(),
|
: const LoginForm(),
|
||||||
),
|
),
|
||||||
// Top-left radial glow - primary accent light
|
// Top-left radial glow - primary accent light
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import '../models/file_item.dart';
|
import '../models/file_item.dart';
|
||||||
|
import '../models/viewer_session.dart';
|
||||||
|
import '../models/editor_session.dart';
|
||||||
|
import '../models/annotation.dart';
|
||||||
|
|
||||||
abstract class FileRepository {
|
abstract class FileRepository {
|
||||||
Future<List<FileItem>> getFiles(String orgId, String path);
|
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> moveFile(String orgId, String sourcePath, String targetPath);
|
||||||
Future<void> renameFile(String orgId, String path, String newName);
|
Future<void> renameFile(String orgId, String path, String newName);
|
||||||
Future<List<FileItem>> searchFiles(String orgId, String query);
|
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/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 '../repositories/file_repository.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
@@ -190,9 +195,25 @@ class MockFileRepository implements FileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<EditorSession> requestEditorSession(
|
||||||
|
String orgId,
|
||||||
|
String fileId,
|
||||||
|
) async {
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
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
|
@override
|
||||||
@@ -270,4 +291,57 @@ class MockFileRepository implements FileRepository {
|
|||||||
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
||||||
.toList();
|
.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/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';
|
import '../repositories/file_repository.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
@@ -69,4 +73,35 @@ class FileService {
|
|||||||
}
|
}
|
||||||
return await _fileRepository.searchFiles(orgId, query);
|
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