good morning

This commit is contained in:
Leon Bösche
2025-12-17 14:48:55 +01:00
parent b6f5b6e243
commit dd1aa4775c
26 changed files with 1186 additions and 97 deletions

View File

@@ -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());
}
}

View File

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

View File

@@ -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];
}

View File

@@ -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());
}
}

View File

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

View File

@@ -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];
}

View 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()));
}
}
}

View File

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

View File

@@ -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];
}

View File

@@ -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']!),
),
],
);

View 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,
};
}
}

View 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,
};
}
}

View 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];
}

View 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'],
);
}
}

View 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']),
);
}
}

View 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']),
);
}
}

View File

@@ -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'));
},

View 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();
}
}

View File

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

View File

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

View File

@@ -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,
);
}

View File

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

View 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,
);
}
}
}

View 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,
);
}
}

View File

@@ -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);
}
}

View 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()],
);
});
}