full stack first commit

This commit is contained in:
Leon Bösche
2025-12-18 00:02:50 +01:00
parent ab7c734ae7
commit b35adc3d06
18 changed files with 717 additions and 85 deletions

View File

@@ -0,0 +1,25 @@
import 'package:bloc/bloc.dart';
import 'activity_event.dart';
import 'activity_state.dart';
import '../../services/activity_api.dart';
class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
final ActivityApi activityApi;
ActivityBloc(this.activityApi) : super(ActivityInitial()) {
on<LoadOrgActivities>(_onLoadOrgActivities);
}
void _onLoadOrgActivities(
LoadOrgActivities event,
Emitter<ActivityState> emit,
) async {
emit(ActivityLoading());
try {
final activities = await activityApi.getOrgActivities(event.orgId);
emit(ActivityLoaded(activities: activities));
} catch (e) {
emit(ActivityError(e.toString()));
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class ActivityEvent extends Equatable {
const ActivityEvent();
@override
List<Object> get props => [];
}
class LoadOrgActivities extends ActivityEvent {
final String orgId;
const LoadOrgActivities(this.orgId);
@override
List<Object> get props => [orgId];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../organization/organization_state.dart';
abstract class ActivityState extends Equatable {
const ActivityState();
@override
List<Object> get props => [];
}
class ActivityInitial extends ActivityState {}
class ActivityLoading extends ActivityState {}
class ActivityLoaded extends ActivityState {
final List<Activity> activities;
const ActivityLoaded({required this.activities});
@override
List<Object> get props => [activities];
}
class ActivityError extends ActivityState {
final String error;
const ActivityError(this.error);
@override
List<Object> get props => [error];
}

View File

@@ -11,14 +11,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
// Simulate API call to go.b0esche.cloud/auth/login
await Future.delayed(const Duration(seconds: 1));
if (event.email.isNotEmpty && event.password.isNotEmpty) {
// Assume JWT received
emit(AuthAuthenticated(token: 'fake-jwt', userId: 'user123'));
} else {
emit(const AuthFailure('Invalid credentials'));
}
// Redirect to Go auth/login
// For web, use window.location or url_launcher
// Assume handled in UI
emit(const AuthFailure('Redirect to login URL'));
}
void _onLogoutRequested(

View File

@@ -7,15 +7,7 @@ abstract class AuthEvent extends Equatable {
List<Object> get props => [];
}
class LoginRequested extends AuthEvent {
final String email;
final String password;
const LoginRequested(this.email, this.password);
@override
List<Object> get props => [email, password];
}
class LoginRequested extends AuthEvent {}
class LogoutRequested extends AuthEvent {}

View File

@@ -8,14 +8,20 @@ import '../file_browser/file_browser_bloc.dart';
import '../file_browser/file_browser_event.dart';
import '../upload/upload_bloc.dart';
import '../upload/upload_event.dart';
import '../../services/org_api.dart';
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
final PermissionBloc permissionBloc;
final FileBrowserBloc fileBrowserBloc;
final UploadBloc uploadBloc;
final OrgApi orgApi;
OrganizationBloc(this.permissionBloc, this.fileBrowserBloc, this.uploadBloc)
: super(OrganizationInitial()) {
OrganizationBloc(
this.permissionBloc,
this.fileBrowserBloc,
this.uploadBloc,
this.orgApi,
) : super(OrganizationInitial()) {
on<LoadOrganizations>(_onLoadOrganizations);
on<SelectOrganization>(_onSelectOrganization);
on<CreateOrganization>(_onCreateOrganization);
@@ -27,14 +33,13 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
) async {
emit(OrganizationLoading());
try {
// Simulate loading orgs from API
await Future.delayed(const Duration(seconds: 1));
final orgs = [
const Organization(id: 'org1', name: 'Personal', role: 'admin'),
const Organization(id: 'org2', name: 'Company Inc', role: 'edit'),
const Organization(id: 'org3', name: 'Side Project', role: 'admin'),
];
emit(OrganizationLoaded(organizations: orgs, selectedOrg: orgs.first));
final orgs = await orgApi.getOrganizations();
emit(
OrganizationLoaded(
organizations: orgs,
selectedOrg: orgs.isNotEmpty ? orgs.first : null,
),
);
} catch (e) {
emit(OrganizationError(e.toString()));
}
@@ -103,13 +108,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
),
);
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
final newOrg = Organization(
id: 'org${currentState.organizations.length + 1}',
name: name,
role: 'admin',
);
final newOrg = await orgApi.createOrganization(name);
final updatedOrgs = [...currentState.organizations, newOrg];
emit(
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),

View File

@@ -3,20 +3,70 @@ import 'package:equatable/equatable.dart';
class Organization extends Equatable {
final String id;
final String name;
final String role; // view, edit, admin
final String? shortCode;
final int? color;
final String slug;
final DateTime createdAt;
const Organization({
required this.id,
required this.name,
required this.role,
this.shortCode,
this.color,
required this.slug,
required this.createdAt,
});
@override
List<Object?> get props => [id, name, role, shortCode, color];
List<Object?> get props => [id, name, slug, createdAt];
factory Organization.fromJson(Map<String, dynamic> json) {
return Organization(
id: json['id'],
name: json['name'],
slug: json['slug'],
createdAt: DateTime.parse(json['createdAt']),
);
}
}
class Activity extends Equatable {
final String id;
final String userId;
final String orgId;
final String? fileId;
final String action;
final Map<String, dynamic> metadata;
final DateTime timestamp;
const Activity({
required this.id,
required this.userId,
required this.orgId,
this.fileId,
required this.action,
required this.metadata,
required this.timestamp,
});
@override
List<Object?> get props => [
id,
userId,
orgId,
fileId,
action,
metadata,
timestamp,
];
factory Activity.fromJson(Map<String, dynamic> json) {
return Activity(
id: json['id'],
userId: json['userId'],
orgId: json['orgId'],
fileId: json['fileId'],
action: json['action'],
metadata: json['metadata'] ?? {},
timestamp: DateTime.parse(json['timestamp']),
);
}
}
abstract class OrganizationState extends Equatable {

View File

@@ -15,8 +15,8 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
final expiresAt = DateTime.now().add(
const Duration(hours: 1),
); // Fake expiry
const Duration(minutes: 15),
); // Match Go
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
@@ -27,7 +27,7 @@ class SessionBloc extends Bloc<SessionEvent, SessionState> {
}
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
final expiresAt = DateTime.now().add(const Duration(hours: 1));
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}

View File

@@ -7,8 +7,12 @@ import 'blocs/organization/organization_bloc.dart';
import 'blocs/permission/permission_bloc.dart';
import 'blocs/file_browser/file_browser_bloc.dart';
import 'blocs/upload/upload_bloc.dart';
import 'blocs/activity/activity_bloc.dart';
import 'repositories/mock_file_repository.dart';
import 'services/file_service.dart';
import 'services/api_client.dart';
import 'services/org_api.dart';
import 'services/activity_api.dart';
import 'pages/home_page.dart';
import 'pages/login_form.dart';
import 'pages/file_explorer.dart';
@@ -58,10 +62,13 @@ class MainApp extends StatelessWidget {
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
BlocProvider<FileBrowserBloc>(
create: (_) => FileBrowserBloc(FileService(MockFileRepository())),
create: (context) => FileBrowserBloc(
FileService(ApiClient(context.read<SessionBloc>())),
),
),
BlocProvider<UploadBloc>(
create: (_) => UploadBloc(MockFileRepository()),
create: (_) =>
UploadBloc(MockFileRepository()), // keep mock for upload
),
BlocProvider<OrganizationBloc>(
lazy: true,
@@ -69,8 +76,13 @@ class MainApp extends StatelessWidget {
context.read<PermissionBloc>(),
context.read<FileBrowserBloc>(),
context.read<UploadBloc>(),
OrgApi(ApiClient(context.read<SessionBloc>())),
),
),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
),
],
child: MaterialApp.router(
routerConfig: _router,

View File

@@ -110,8 +110,8 @@ class _LoginFormState extends State<LoginForm> {
onPressed: () {
context.read<AuthBloc>().add(
LoginRequested(
_emailController.text,
_passwordController.text,
// _emailController.text,
// _passwordController.text,
),
);
},

View File

@@ -0,0 +1,15 @@
import '../blocs/organization/organization_state.dart';
import 'api_client.dart';
class ActivityApi {
final ApiClient _apiClient;
ActivityApi(this._apiClient);
Future<List<Activity>> getOrgActivities(String orgId) async {
return await _apiClient.getList(
'/orgs/$orgId/activity',
fromJson: (data) => Activity.fromJson(data),
);
}
}

View File

@@ -102,6 +102,19 @@ class ApiClient {
}
}
Future<List<T>> getList<T>(
String path, {
Map<String, dynamic>? queryParameters,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
return (response.data as List).map(fromJson).toList();
} on DioException catch (e) {
throw _handleError(e);
}
}
ApiError _handleError(DioException e) {
final status = e.response?.statusCode;
final data = e.response?.data;

View File

@@ -3,38 +3,39 @@ import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../repositories/file_repository.dart';
import 'api_client.dart';
class FileService {
final FileRepository _fileRepository;
final ApiClient _apiClient;
FileService(this._fileRepository);
FileService(this._apiClient);
Future<List<FileItem>> getFiles(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
return await _fileRepository.getFiles(orgId, path);
return await _apiClient.getList(
'/orgs/$orgId/files',
fromJson: (data) => FileItem(
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
size: data['size'],
lastModified: DateTime.parse(data['lastModified']),
),
);
}
Future<FileItem?> getFile(String orgId, String path) async {
if (path.isEmpty) {
return null;
}
return await _fileRepository.getFile(orgId, path);
throw UnimplementedError();
}
Future<void> uploadFile(String orgId, FileItem file) async {
if (file.name.isEmpty) {
throw Exception('File name cannot be empty');
}
await _fileRepository.uploadFile(orgId, file);
throw UnimplementedError();
}
Future<void> deleteFile(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
await _fileRepository.deleteFile(orgId, path);
throw UnimplementedError();
}
Future<void> createFolder(
@@ -42,10 +43,7 @@ class FileService {
String parentPath,
String folderName,
) async {
if (folderName.isEmpty) {
throw Exception('Folder name cannot be empty');
}
await _fileRepository.createFolder(orgId, parentPath, folderName);
throw UnimplementedError();
}
Future<void> moveFile(
@@ -53,24 +51,15 @@ class FileService {
String sourcePath,
String targetPath,
) async {
if (sourcePath.isEmpty || targetPath.isEmpty) {
throw Exception('Paths cannot be empty');
}
await _fileRepository.moveFile(orgId, sourcePath, targetPath);
throw UnimplementedError();
}
Future<void> renameFile(String orgId, String path, String newName) async {
if (path.isEmpty || newName.isEmpty) {
throw Exception('Path and new name cannot be empty');
}
await _fileRepository.renameFile(orgId, path, newName);
throw UnimplementedError();
}
Future<List<FileItem>> searchFiles(String orgId, String query) async {
if (query.isEmpty) {
return [];
}
return await _fileRepository.searchFiles(orgId, query);
throw UnimplementedError();
}
Future<ViewerSession> requestViewerSession(
@@ -80,7 +69,10 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
return await _fileRepository.requestViewerSession(orgId, fileId);
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/view',
fromJson: (data) => ViewerSession.fromJson(data),
);
}
Future<EditorSession> requestEditorSession(
@@ -90,7 +82,10 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
return await _fileRepository.requestEditorSession(orgId, fileId);
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/edit',
fromJson: (data) => EditorSession.fromJson(data),
);
}
Future<void> saveAnnotations(
@@ -101,6 +96,13 @@ class FileService {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
}
await _fileRepository.saveAnnotations(orgId, fileId, annotations);
await _apiClient.post(
'/orgs/$orgId/files/$fileId/annotations',
data: {
'annotations': annotations.map((a) => a.toJson()).toList(),
'baseVersionId': '1', // mock
},
fromJson: (data) => null,
);
}
}

View File

@@ -0,0 +1,23 @@
import '../blocs/organization/organization_state.dart';
import 'api_client.dart';
class OrgApi {
final ApiClient _apiClient;
OrgApi(this._apiClient);
Future<List<Organization>> getOrganizations() async {
return await _apiClient.getList(
'/orgs',
fromJson: (data) => Organization.fromJson(data),
);
}
Future<Organization> createOrganization(String name) async {
return await _apiClient.post(
'/orgs',
data: {'name': name},
fromJson: (data) => Organization.fromJson(data),
);
}
}