completed migrating data scope from user to workspace

This commit is contained in:
2026-06-22 22:10:38 +02:00
parent 62c7f53de0
commit ae2705363a
37 changed files with 918 additions and 221 deletions

View File

@@ -0,0 +1,47 @@
class WorkspaceModel {
final String id;
final String createdAt;
final String updatedAt;
final bool deleted;
final String name;
final String description;
final String phone;
final String email;
WorkspaceModel({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deleted,
required this.name,
required this.description,
required this.phone,
required this.email,
});
factory WorkspaceModel.fromJson(Map<String, dynamic> json) {
return WorkspaceModel(
id: json['id'] as String,
createdAt: json['createdAt'] as String,
updatedAt: json['updatedAt'] as String,
deleted: json['deleted'] as bool,
name: json['name'] as String,
description: json['description'] as String,
phone: json['phone'] as String,
email: json['email'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'createdAt': createdAt,
'updatedAt': updatedAt,
'deleted': deleted,
'name': name,
'description': description,
'phone': phone,
'email': email,
};
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/screens/workspaces/workspace_model.dart';
/// Describes the current state of the workspace fetch operation.
enum WorkspaceStatus { idle, loading, success, error }
/// A [ChangeNotifier] that fetches and caches the list of workspaces
/// for the current user from the backend.
///
/// Usage:
/// ```dart
/// final provider = context.read<WorkspaceProvider>();
/// await provider.fetchWorkspaces(userId: '...');
/// ```
class WorkspaceProvider extends ChangeNotifier {
final Http _http = Http();
WorkspaceStatus _status = WorkspaceStatus.idle;
List<WorkspaceModel> _workspaces = [];
String? _error;
WorkspaceStatus get status => _status;
List<WorkspaceModel> get workspaces => _workspaces;
String? get error => _error;
bool get isLoading => _status == WorkspaceStatus.loading;
bool get hasError => _status == WorkspaceStatus.error;
/// Fetches workspaces for the given [userId].
///
/// On success, [workspaces] is updated and [status] becomes
/// [WorkspaceStatus.success].
/// On failure, [error] is set and [status] becomes
/// [WorkspaceStatus.error].
Future<void> fetchWorkspaces({required String userId}) async {
_status = WorkspaceStatus.loading;
_error = null;
notifyListeners();
try {
final response = await _http.getRaw('/workspaces?userId=$userId');
if (response.statusCode == 200) {
final body = response.data as Map<String, dynamic>;
final content = body['content'] as List<dynamic>;
_workspaces = content
.map((e) => WorkspaceModel.fromJson(e as Map<String, dynamic>))
.toList();
_status = WorkspaceStatus.success;
} else if (response.statusCode == 403) {
_error =
'Access denied. You do not have permission to view workspaces.';
_status = WorkspaceStatus.error;
} else {
_error = 'Failed to load workspaces (status ${response.statusCode})';
_status = WorkspaceStatus.error;
}
} catch (e) {
_error = 'Network error: ${e.toString()}';
_status = WorkspaceStatus.error;
}
notifyListeners();
}
/// Clears the current workspace list and resets to idle.
void reset() {
_status = WorkspaceStatus.idle;
_workspaces = [];
_error = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/workspaces/workspace_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../../auth_state.dart';
/// Full-screen workspace selector displayed immediately after the splash
/// screen. Fetches the user's workspaces from the backend and:
///
/// * If exactly one workspace exists → auto-navigates to the home screen.
/// * If multiple workspaces exist → shows a picker so the user can choose.
/// * On error (e.g. 403 for guests) → falls through to the home screen.
class WorkspaceScreen extends StatefulWidget {
const WorkspaceScreen({super.key});
@override
State<WorkspaceScreen> createState() => _WorkspaceScreenState();
}
class _WorkspaceScreenState extends State<WorkspaceScreen>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
AuthState authState = AuthState();
bool _navigated = false;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _fadeController, curve: Curves.easeOut));
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.04), end: Offset.zero).animate(
CurvedAnimation(parent: _fadeController, curve: Curves.easeOutCubic),
);
// Kick off the workspace fetch after the first frame.
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAuthAndLoad();
});
}
Future<void> _checkAuthAndLoad() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getString("token") != null) {
_loadWorkspaces();
} else {
// if not logged in then create temp workspace
SharedPreferences.getInstance().then((prefs) {
prefs.setString('workspaceId', const Uuid().v4());
});
_navigateToHome();
}
}
Future<void> _loadWorkspaces() async {
// Ensure a userId exists in prefs.
final prefs = await SharedPreferences.getInstance();
if (prefs.getString("userId") == null) {
await prefs.setString("userId", const Uuid().v4());
}
final userId = prefs.getString("userId")!;
if (!mounted) return;
final provider = context.read<WorkspaceProvider>();
// Start the fade-in animation.
_fadeController.forward();
await provider.fetchWorkspaces(userId: userId);
if (!mounted) return;
_evaluateNavigation(provider);
}
void _evaluateNavigation(WorkspaceProvider provider) {
if (_navigated) return;
final workspaces = provider.workspaces;
if (workspaces.length == 1) {
// Store the selected workspace ID so downstream screens can
// reference it if needed.
SharedPreferences.getInstance().then((prefs) {
prefs.setString('workspaceId', workspaces.first.id);
});
_navigateToHome();
} else if (workspaces.isEmpty) {
// No workspaces or an error occurred go to home.
_navigateToHome();
}
// 2+ workspaces: stay on this screen so the user can pick one.
}
void _onWorkspaceSelected(String workspaceId) async {
if (_navigated) return;
_navigated = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('workspaceId', workspaceId);
if (!mounted) return;
_navigateToHome();
}
void _navigateToHome() {
if (_navigated) return;
_navigated = true;
context.go('/');
}
@override
void dispose() {
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: Consumer<WorkspaceProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Error or empty: navigate away (handled above, but show
// a brief fallback if navigation hasn't triggered yet).
if (provider.hasError || provider.workspaces.isEmpty) {
return const SizedBox.shrink();
}
// Multiple workspaces → show the picker.
final workspaces = provider.workspaces;
return SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(flex: 2),
// Header
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.business_rounded,
color: theme.colorScheme.primary,
size: 28,
),
),
const SizedBox(height: 24),
Text(
'Select a workspace',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Choose the workspace you want to work in.',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 32),
// Workspace list
Expanded(
child: ListView.separated(
itemCount: workspaces.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final ws = workspaces[index];
return _WorkspaceCard(
name: ws.name,
description: ws.description,
email: ws.email,
phone: ws.phone,
onTap: () => _onWorkspaceSelected(ws.id),
);
},
),
),
const Spacer(flex: 1),
],
),
),
),
),
);
},
),
);
}
}
/// A single workspace card shown in the picker list.
class _WorkspaceCard extends StatelessWidget {
final String name;
final String description;
final String email;
final String phone;
final VoidCallback onTap;
const _WorkspaceCard({
required this.name,
required this.description,
required this.email,
required this.phone,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
),
),
child: Row(
children: [
// Avatar placeholder
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : 'W',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
),
),
),
),
const SizedBox(width: 16),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (email.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.email_outlined,
size: 14,
color: Colors.grey.shade400,
),
const SizedBox(width: 6),
Expanded(
child: Text(
email,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right_rounded, color: Colors.grey.shade400),
],
),
),
),
);
}
}