added uptime monitoring
This commit is contained in:
@@ -8,6 +8,7 @@ import 'package:qpay/screens/transaction_controller.dart';
|
|||||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
import 'package:qpay/providers/splash_provider.dart';
|
import 'package:qpay/providers/splash_provider.dart';
|
||||||
import 'package:qpay/screens/workspaces/workspace_provider.dart';
|
import 'package:qpay/screens/workspaces/workspace_provider.dart';
|
||||||
|
import 'package:qpay/providers/uptime_provider.dart';
|
||||||
import 'package:qpay/theme/app_theme.dart';
|
import 'package:qpay/theme/app_theme.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
@@ -57,6 +58,9 @@ void main() async {
|
|||||||
ChangeNotifierProvider<WorkspaceProvider>(
|
ChangeNotifierProvider<WorkspaceProvider>(
|
||||||
create: (_) => WorkspaceProvider(),
|
create: (_) => WorkspaceProvider(),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<UptimeProvider>(
|
||||||
|
create: (_) => UptimeProvider(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
35
lib/models/uptime_item_model.dart
Normal file
35
lib/models/uptime_item_model.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class UptimeItemModel {
|
||||||
|
final String id;
|
||||||
|
final String createdAt;
|
||||||
|
final bool deleted;
|
||||||
|
final String name;
|
||||||
|
final bool status;
|
||||||
|
|
||||||
|
const UptimeItemModel({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.deleted,
|
||||||
|
required this.name,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UptimeItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UptimeItemModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
createdAt: json['createdAt'] as String,
|
||||||
|
deleted: json['deleted'] as bool,
|
||||||
|
name: json['name'] as String,
|
||||||
|
status: json['status'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
'deleted': deleted,
|
||||||
|
'name': name,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
"selectedIcon": Icon(Icons.history),
|
"selectedIcon": Icon(Icons.history),
|
||||||
"label": 'History',
|
"label": 'History',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": Icon(Icons.more_horiz),
|
||||||
|
"selectedIcon": Icon(Icons.more_horiz),
|
||||||
|
"label": 'More',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +176,9 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
case 2:
|
case 2:
|
||||||
context.go('/history');
|
context.go('/history');
|
||||||
break;
|
break;
|
||||||
|
case 3:
|
||||||
|
context.go('/more');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +186,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
final String location = GoRouterState.of(context).uri.path;
|
final String location = GoRouterState.of(context).uri.path;
|
||||||
if (location.startsWith('/groups')) return 1;
|
if (location.startsWith('/groups')) return 1;
|
||||||
if (location.startsWith('/history')) return 2;
|
if (location.startsWith('/history')) return 2;
|
||||||
|
if (location.startsWith('/more') || location.startsWith('/users') || location.startsWith('/uptime')) return 3;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
lib/providers/uptime_provider.dart
Normal file
54
lib/providers/uptime_provider.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:qpay/http/http.dart';
|
||||||
|
import 'package:qpay/models/uptime_item_model.dart';
|
||||||
|
|
||||||
|
enum UptimeStatus { idle, loading, success, error }
|
||||||
|
|
||||||
|
class UptimeProvider extends ChangeNotifier {
|
||||||
|
final Http _http = Http();
|
||||||
|
|
||||||
|
UptimeStatus _status = UptimeStatus.idle;
|
||||||
|
List<UptimeItemModel> _items = [];
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
UptimeStatus get status => _status;
|
||||||
|
List<UptimeItemModel> get items => _items;
|
||||||
|
String? get error => _error;
|
||||||
|
bool get isLoading => _status == UptimeStatus.loading;
|
||||||
|
bool get hasError => _status == UptimeStatus.error;
|
||||||
|
|
||||||
|
/// Count of services that are currently up (status == true).
|
||||||
|
int get upCount => _items.where((i) => i.status).length;
|
||||||
|
|
||||||
|
/// Count of services that are currently down (status == false).
|
||||||
|
int get downCount => _items.where((i) => !i.status).length;
|
||||||
|
|
||||||
|
Future<void> fetchUptimes() async {
|
||||||
|
_status = UptimeStatus.loading;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _http.getRaw(
|
||||||
|
'/public/uptimes?sort=createdAt%2Cdesc',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final body = response.data as Map<String, dynamic>;
|
||||||
|
final content = body['content'] as List<dynamic>;
|
||||||
|
_items = content
|
||||||
|
.map((e) => UptimeItemModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
_status = UptimeStatus.success;
|
||||||
|
} else {
|
||||||
|
_error = 'Failed to load uptime data (status ${response.statusCode})';
|
||||||
|
_status = UptimeStatus.error;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Network error: ${e.toString()}';
|
||||||
|
_status = UptimeStatus.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ import 'package:qpay/screens/receipt/receipt_screen.dart';
|
|||||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||||
import 'package:qpay/screens/splash_screen.dart';
|
import 'package:qpay/screens/splash_screen.dart';
|
||||||
import 'package:qpay/screens/workspaces/workspace_screen.dart';
|
import 'package:qpay/screens/workspaces/workspace_screen.dart';
|
||||||
|
import 'package:qpay/screens/users/screens/user_list_screen.dart';
|
||||||
|
import 'package:qpay/screens/more/more_screen.dart';
|
||||||
|
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
|
||||||
|
|
||||||
/// Tracks whether the splash animation is complete so that
|
/// Tracks whether the splash animation is complete so that
|
||||||
/// GoRouter's redirect can show the splash first, then release
|
/// GoRouter's redirect can show the splash first, then release
|
||||||
@@ -65,7 +68,7 @@ final SplashState splashState = SplashState();
|
|||||||
/// Route prefixes that require the user to be signed in. Any URL
|
/// Route prefixes that require the user to be signed in. Any URL
|
||||||
/// whose path starts with one of these strings will be redirected
|
/// whose path starts with one of these strings will be redirected
|
||||||
/// to the sign-in screen when no auth token is present.
|
/// to the sign-in screen when no auth token is present.
|
||||||
const List<String> _authProtectedRoutePrefixes = ['/groups'];
|
const List<String> _authProtectedRoutePrefixes = ['/groups', '/users'];
|
||||||
|
|
||||||
bool _isAuthProtected(String path) {
|
bool _isAuthProtected(String path) {
|
||||||
for (final prefix in _authProtectedRoutePrefixes) {
|
for (final prefix in _authProtectedRoutePrefixes) {
|
||||||
@@ -225,6 +228,18 @@ GoRouter buildRouter() {
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => const ProfileScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more',
|
||||||
|
builder: (context, state) => const MoreScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/uptime',
|
||||||
|
builder: (context, state) => const UptimeStatusScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/users',
|
||||||
|
builder: (context, state) => const UserListScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/groups',
|
path: '/groups',
|
||||||
builder: (context, state) => const GroupsScreen(),
|
builder: (context, state) => const GroupsScreen(),
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
|||||||
_isLoadingZwG = true;
|
_isLoadingZwG = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// no need to proceed if not logged in
|
||||||
|
if (!_isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch USD balance
|
// Fetch USD balance
|
||||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
if (prefs.getString("workspaceId") == null) {
|
if (prefs.getString("workspaceId") == null) {
|
||||||
prefs.setString("workspaceId", Uuid().v4());
|
prefs.setString("workspaceId", Uuid().v4());
|
||||||
}
|
}
|
||||||
|
if (prefs.getString("userId") == null) {
|
||||||
|
prefs.setString("userId", Uuid().v4());
|
||||||
|
}
|
||||||
|
|
||||||
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||||
}
|
}
|
||||||
@@ -168,6 +171,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
if (prefs.getString("workspaceId") == null) {
|
if (prefs.getString("workspaceId") == null) {
|
||||||
prefs.setString("workspaceId", Uuid().v4());
|
prefs.setString("workspaceId", Uuid().v4());
|
||||||
}
|
}
|
||||||
|
if (prefs.getString("userId") == null) {
|
||||||
|
prefs.setString("userId", Uuid().v4());
|
||||||
|
}
|
||||||
|
|
||||||
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||||
}
|
}
|
||||||
|
|||||||
80
lib/screens/more/more_screen.dart
Normal file
80
lib/screens/more/more_screen.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../models/responsive_policy.dart';
|
||||||
|
|
||||||
|
class MoreScreen extends StatelessWidget {
|
||||||
|
const MoreScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('More'), centerTitle: false),
|
||||||
|
body: Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||||
|
? 600.0
|
||||||
|
: double.infinity;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: maxWidth,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: 'Users',
|
||||||
|
subtitle: 'Manage users and permissions',
|
||||||
|
onTap: () => context.go('/users'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.monitor_heart_outlined,
|
||||||
|
title: 'Uptime Status',
|
||||||
|
subtitle: 'View system uptime and service status',
|
||||||
|
onTap: () => context.push('/uptime'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavOptionTile extends StatelessWidget {
|
||||||
|
const _NavOptionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
leading: Icon(icon, size: 28, color: theme.colorScheme.primary),
|
||||||
|
title: Text(title, style: theme.textTheme.titleMedium),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(subtitle, style: theme.textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(100),
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,15 +71,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (response.isSuccess) {
|
if (response.isSuccess) {
|
||||||
AppSnackBar.showSuccess(context, 'Login successful!');
|
AppSnackBar.showSuccess(context, 'Login successful!');
|
||||||
|
context.go('/workspace');
|
||||||
} else {
|
} else {
|
||||||
AppSnackBar.showError(context, response.error ?? 'Login failed');
|
AppSnackBar.showError(context, response.error ?? 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (response.isSuccess) {
|
|
||||||
context.go('/workspace');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,7 +85,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
trailingRoute: '/onboarding/phone',
|
trailingRoute: '/onboarding/phone',
|
||||||
trailingIcon: Icons.person_add_alt_1_rounded,
|
trailingIcon: Icons.person_add_alt_1_rounded,
|
||||||
bottomLinkLabel: 'Continue as guest?',
|
bottomLinkLabel: 'Continue as guest?',
|
||||||
bottomLinkRoute: '/',
|
bottomLinkRoute: '/workspace',
|
||||||
child: OnboardingCard(
|
child: OnboardingCard(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ class ReceiptController extends ChangeNotifier {
|
|||||||
model.transaction = transaction;
|
model.transaction = transaction;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return ApiResponse.success(transaction);
|
return ApiResponse.success(transaction);
|
||||||
} catch (e) {
|
} catch (e, s) {
|
||||||
|
print(e);
|
||||||
|
print(s);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return ApiResponse.failure(
|
return ApiResponse.failure(
|
||||||
"Problem fetching transaction data, are you connected to the internet?",
|
"Problem fetching transaction data, are you connected to the internet?",
|
||||||
|
|||||||
@@ -119,18 +119,23 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchTransaction() async {
|
Future<void> _fetchTransaction() async {
|
||||||
final id =
|
try {
|
||||||
widget.transactionId ??
|
final id =
|
||||||
transactionController.model.receiptData?["id"] ??
|
widget.transactionId ??
|
||||||
receiptController.model.transaction?.id;
|
transactionController.model.receiptData?["id"] ??
|
||||||
|
receiptController.model.transaction?.id;
|
||||||
|
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
|
||||||
final response = await receiptController.getReceiptData(id);
|
final response = await receiptController.getReceiptData(id);
|
||||||
if (response.isSuccess && response.data != null) {
|
if (response.isSuccess && response.data != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_transaction = response.data;
|
_transaction = response.data;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
print(e);
|
||||||
|
print(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
421
lib/screens/uptime/uptime_status_screen.dart
Normal file
421
lib/screens/uptime/uptime_status_screen.dart
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/providers/uptime_provider.dart';
|
||||||
|
import 'package:qpay/models/uptime_item_model.dart';
|
||||||
|
|
||||||
|
class UptimeStatusScreen extends StatefulWidget {
|
||||||
|
const UptimeStatusScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UptimeStatusScreen> createState() => _UptimeStatusScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UptimeStatusScreenState extends State<UptimeStatusScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<UptimeProvider>().fetchUptimes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Uptime Status'), centerTitle: false),
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: kIsWeb ? 600 : double.infinity,
|
||||||
|
child: Consumer<UptimeProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.hasError) {
|
||||||
|
return _ErrorView(
|
||||||
|
message: provider.error ?? 'An unexpected error occurred.',
|
||||||
|
onRetry: () => provider.fetchUptimes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.status == UptimeStatus.idle) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.monitor_heart_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.primary.withAlpha(100),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Uptime Status',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Pull to load service status.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = provider.items;
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(80),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No services found.',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => provider.fetchUptimes(),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_SummaryBar(
|
||||||
|
upCount: provider.upCount,
|
||||||
|
downCount: provider.downCount,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _UptimeCard(item: items[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryBar extends StatelessWidget {
|
||||||
|
const _SummaryBar({required this.upCount, required this.downCount});
|
||||||
|
|
||||||
|
final int upCount;
|
||||||
|
final int downCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary,
|
||||||
|
theme.colorScheme.primary.withAlpha(220),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.colorScheme.primary.withAlpha(60),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_StatBadge(
|
||||||
|
label: 'Up',
|
||||||
|
count: upCount,
|
||||||
|
color: const Color(0xFF10B981),
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 36,
|
||||||
|
width: 1,
|
||||||
|
color: Colors.white.withAlpha(60),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
),
|
||||||
|
_StatBadge(
|
||||||
|
label: 'Down',
|
||||||
|
count: downCount,
|
||||||
|
color: const Color(0xFFEF4444),
|
||||||
|
icon: Icons.cancel,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${upCount + downCount}',
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Total',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatBadge extends StatelessWidget {
|
||||||
|
const _StatBadge({
|
||||||
|
required this.label,
|
||||||
|
required this.count,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int count;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withAlpha(40),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$count',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UptimeCard extends StatelessWidget {
|
||||||
|
const _UptimeCard({required this.item});
|
||||||
|
|
||||||
|
final UptimeItemModel item;
|
||||||
|
|
||||||
|
IconData _iconForService(String name) {
|
||||||
|
final lower = name.toLowerCase();
|
||||||
|
if (lower.contains('visa') || lower.contains('mastercard')) {
|
||||||
|
return Icons.credit_card;
|
||||||
|
}
|
||||||
|
if (lower.contains('ecocash')) {
|
||||||
|
return Icons.account_balance_wallet;
|
||||||
|
}
|
||||||
|
if (lower.contains('econet')) {
|
||||||
|
return Icons.signal_cellular_alt;
|
||||||
|
}
|
||||||
|
if (lower.contains('netone')) {
|
||||||
|
return Icons.cell_tower;
|
||||||
|
}
|
||||||
|
if (lower.contains('zesa')) {
|
||||||
|
return Icons.electric_bolt;
|
||||||
|
}
|
||||||
|
return Icons.dns;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(String iso) {
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(iso);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return '${dt.day}/${dt.month}/${dt.year}';
|
||||||
|
} catch (_) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isUp = item.status;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUp ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_iconForService(item.name),
|
||||||
|
size: 22,
|
||||||
|
color: isUp ? const Color(0xFF065F46) : const Color(0xFF991B1B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Last checked ${_formatTimestamp(item.createdAt)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUp ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUp
|
||||||
|
? const Color(0xFF10B981)
|
||||||
|
: const Color(0xFFEF4444),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
isUp ? 'Up' : 'Down',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isUp
|
||||||
|
? const Color(0xFF065F46)
|
||||||
|
: const Color(0xFF991B1B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorView extends StatelessWidget {
|
||||||
|
const _ErrorView({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 56,
|
||||||
|
color: theme.colorScheme.error.withAlpha(150),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Failed to Load', style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(150),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
lib/screens/users/controllers/user_controller.dart
Normal file
325
lib/screens/users/controllers/user_controller.dart
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qpay/http/http.dart';
|
||||||
|
import 'package:qpay/models/api_response.dart';
|
||||||
|
import 'package:qpay/models/pageable_model.dart';
|
||||||
|
import 'package:qpay/screens/users/models/managed_user_model.dart';
|
||||||
|
|
||||||
|
class UserScreenModel {
|
||||||
|
List<ManagedUserModel> users = [];
|
||||||
|
bool isLoading = false;
|
||||||
|
bool isLoadingMore = false;
|
||||||
|
String? errorMessage;
|
||||||
|
int currentPage = 0;
|
||||||
|
int totalPages = 0;
|
||||||
|
int totalElements = 0;
|
||||||
|
String searchQuery = '';
|
||||||
|
bool selectMode = false;
|
||||||
|
Set<String> selectedUserIds = {};
|
||||||
|
|
||||||
|
/// Active filter values (null = not filtered)
|
||||||
|
String? filterEmail;
|
||||||
|
String? filterPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserController extends ChangeNotifier {
|
||||||
|
final UserScreenModel model = UserScreenModel();
|
||||||
|
final Http http = Http();
|
||||||
|
BuildContext? context;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
UserController(this.context);
|
||||||
|
|
||||||
|
/// Creates a controller without a [BuildContext] — useful when a
|
||||||
|
/// bottom sheet or dialog needs to drive API calls through the
|
||||||
|
/// controller without participating in the widget tree.
|
||||||
|
UserController.forSheet() : context = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _unwrap(dynamic response) {
|
||||||
|
if (response is Map && response.containsKey('body')) {
|
||||||
|
return response['body'];
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildQueryParameters(Map<String, String?> params) {
|
||||||
|
final filtered = <String, String>{};
|
||||||
|
params.forEach((key, value) {
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (filtered.isEmpty) return '';
|
||||||
|
return filtered.entries.map((e) => '${e.key}=${e.value}').join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 1. LIST USERS (GET /api/users?username=…&email=…&phone=…)
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
Future<ApiResponse<PageableModel>> listUsers({
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
String? username,
|
||||||
|
String? email,
|
||||||
|
String? phone,
|
||||||
|
}) async {
|
||||||
|
if (page == 0) {
|
||||||
|
model.isLoading = true;
|
||||||
|
model.users = [];
|
||||||
|
model.currentPage = 0;
|
||||||
|
} else {
|
||||||
|
model.isLoadingMore = true;
|
||||||
|
}
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final params = <String, String?>{
|
||||||
|
'page': page.toString(),
|
||||||
|
'size': size.toString(),
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
final raw = await http.get('/users?${buildQueryParameters(params)}');
|
||||||
|
final response = _unwrap(raw);
|
||||||
|
final pageableModel = PageableModel.fromJson(
|
||||||
|
response as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newUsers = pageableModel.content
|
||||||
|
.map((e) => ManagedUserModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (page == 0) {
|
||||||
|
model.users = newUsers;
|
||||||
|
} else {
|
||||||
|
model.users.addAll(newUsers);
|
||||||
|
}
|
||||||
|
model.currentPage = pageableModel.number;
|
||||||
|
model.totalPages = pageableModel.totalPages;
|
||||||
|
model.totalElements = pageableModel.totalElements;
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.success(pageableModel);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
if (page == 0) {
|
||||||
|
model.users = [];
|
||||||
|
}
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to load users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 2. LIST WORKSPACE USERS (GET /api/users/workspace?workspaceId=…)
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
Future<ApiResponse<PageableModel>> listWorkspaceUsers({
|
||||||
|
required String workspaceId,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
String? username,
|
||||||
|
String? email,
|
||||||
|
String? phone,
|
||||||
|
}) async {
|
||||||
|
if (page == 0) {
|
||||||
|
model.isLoading = true;
|
||||||
|
model.users = [];
|
||||||
|
model.currentPage = 0;
|
||||||
|
} else {
|
||||||
|
model.isLoadingMore = true;
|
||||||
|
}
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final params = <String, String?>{
|
||||||
|
'workspaceId': workspaceId,
|
||||||
|
'page': page.toString(),
|
||||||
|
'size': size.toString(),
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
final raw = await http.get(
|
||||||
|
'/users/workspace?${buildQueryParameters(params)}',
|
||||||
|
);
|
||||||
|
final response = _unwrap(raw);
|
||||||
|
final pageableModel = PageableModel.fromJson(
|
||||||
|
response as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newUsers = pageableModel.content
|
||||||
|
.map((e) => ManagedUserModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (page == 0) {
|
||||||
|
model.users = newUsers;
|
||||||
|
} else {
|
||||||
|
model.users.addAll(newUsers);
|
||||||
|
}
|
||||||
|
model.currentPage = pageableModel.number;
|
||||||
|
model.totalPages = pageableModel.totalPages;
|
||||||
|
model.totalElements = pageableModel.totalElements;
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.success(pageableModel);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
if (page == 0) {
|
||||||
|
model.users = [];
|
||||||
|
}
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to load workspace users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 3. ASSOCIATE USER TO WORKSPACE
|
||||||
|
// POST /api/workspaces/{workspaceId}/users/{userId}
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
Future<ApiResponse<void>> associateUser({
|
||||||
|
required String workspaceId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await http.postRaw(
|
||||||
|
'/workspaces/$workspaceId/users/$userId',
|
||||||
|
);
|
||||||
|
// Refresh the list after association so the UI stays in sync
|
||||||
|
await listWorkspaceUsers(workspaceId: workspaceId, page: 0);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to associate user to workspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// 4. DISSOCIATE USER FROM WORKSPACE
|
||||||
|
// DELETE /api/workspaces/{workspaceId}/users/{userId}
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
Future<ApiResponse<void>> dissociateUser({
|
||||||
|
required String workspaceId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await http.delete('/workspaces/$workspaceId/users/$userId');
|
||||||
|
// Refresh the workspace users list after dissociation
|
||||||
|
await listWorkspaceUsers(workspaceId: workspaceId, page: 0);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to dissociate user from workspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Convenience helpers
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> searchUsers({required String workspaceId, String? name}) async {
|
||||||
|
model.searchQuery = name ?? '';
|
||||||
|
await listWorkspaceUsers(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
page: 0,
|
||||||
|
username: name,
|
||||||
|
email: model.filterEmail,
|
||||||
|
phone: model.filterPhone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyFilters({
|
||||||
|
required String workspaceId,
|
||||||
|
String? email,
|
||||||
|
String? phone,
|
||||||
|
}) async {
|
||||||
|
model.filterEmail = email;
|
||||||
|
model.filterPhone = phone;
|
||||||
|
await listWorkspaceUsers(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
page: 0,
|
||||||
|
username: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFilters({required String workspaceId}) {
|
||||||
|
model.filterEmail = null;
|
||||||
|
model.filterPhone = null;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
listWorkspaceUsers(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
page: 0,
|
||||||
|
username: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadNextPage({required String workspaceId}) async {
|
||||||
|
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await listWorkspaceUsers(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
page: model.currentPage + 1,
|
||||||
|
username: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
|
email: model.filterEmail,
|
||||||
|
phone: model.filterPhone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Selection helpers for bulk actions
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void toggleSelectMode() {
|
||||||
|
model.selectMode = !model.selectMode;
|
||||||
|
if (!model.selectMode) {
|
||||||
|
model.selectedUserIds.clear();
|
||||||
|
}
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleUserSelection(String userId) {
|
||||||
|
if (model.selectedUserIds.contains(userId)) {
|
||||||
|
model.selectedUserIds.remove(userId);
|
||||||
|
} else {
|
||||||
|
model.selectedUserIds.add(userId);
|
||||||
|
}
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dissociateSelectedUsers({
|
||||||
|
required String workspaceId,
|
||||||
|
}) async {
|
||||||
|
final idsToProcess = List<String>.from(model.selectedUserIds);
|
||||||
|
model.selectMode = false;
|
||||||
|
model.selectedUserIds.clear();
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
for (final id in idsToProcess) {
|
||||||
|
await dissociateUser(workspaceId: workspaceId, userId: id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
lib/screens/users/models/managed_user_model.dart
Normal file
118
lib/screens/users/models/managed_user_model.dart
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
class ManagedUserModel {
|
||||||
|
final String id;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
final bool deleted;
|
||||||
|
final String username;
|
||||||
|
final String email;
|
||||||
|
final String phone;
|
||||||
|
final int workflowId;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final bool enabled;
|
||||||
|
final bool accountNonExpired;
|
||||||
|
final bool accountNonLocked;
|
||||||
|
final bool credentialsNonExpired;
|
||||||
|
final List<ManagedUserAuthority> authorities;
|
||||||
|
|
||||||
|
ManagedUserModel({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.deleted,
|
||||||
|
required this.username,
|
||||||
|
required this.email,
|
||||||
|
required this.phone,
|
||||||
|
required this.workflowId,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.enabled,
|
||||||
|
required this.accountNonExpired,
|
||||||
|
required this.accountNonLocked,
|
||||||
|
required this.credentialsNonExpired,
|
||||||
|
required this.authorities,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ManagedUserModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ManagedUserModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
createdAt: json['createdAt'] as String? ?? '',
|
||||||
|
updatedAt: json['updatedAt'] as String? ?? '',
|
||||||
|
deleted: json['deleted'] as bool? ?? false,
|
||||||
|
username: json['username'] as String? ?? '',
|
||||||
|
email: json['email'] as String? ?? '',
|
||||||
|
phone: json['phone'] as String? ?? '',
|
||||||
|
workflowId: json['workflowId'] as int? ?? 0,
|
||||||
|
firstName: json['firstName'] as String? ?? '',
|
||||||
|
lastName: json['lastName'] as String? ?? '',
|
||||||
|
enabled: json['enabled'] as bool? ?? true,
|
||||||
|
accountNonExpired: json['accountNonExpired'] as bool? ?? true,
|
||||||
|
accountNonLocked: json['accountNonLocked'] as bool? ?? true,
|
||||||
|
credentialsNonExpired: json['credentialsNonExpired'] as bool? ?? true,
|
||||||
|
authorities: (json['authorities'] as List<dynamic>?)
|
||||||
|
?.map((e) =>
|
||||||
|
ManagedUserAuthority.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
'updatedAt': updatedAt,
|
||||||
|
'deleted': deleted,
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
'workflowId': workflowId,
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'enabled': enabled,
|
||||||
|
'accountNonExpired': accountNonExpired,
|
||||||
|
'accountNonLocked': accountNonLocked,
|
||||||
|
'credentialsNonExpired': credentialsNonExpired,
|
||||||
|
'authorities': authorities.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
if (firstName.isNotEmpty && lastName.isNotEmpty) {
|
||||||
|
return '$firstName $lastName';
|
||||||
|
}
|
||||||
|
if (firstName.isNotEmpty) return firstName;
|
||||||
|
if (lastName.isNotEmpty) return lastName;
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get initials {
|
||||||
|
if (firstName.isNotEmpty && lastName.isNotEmpty) {
|
||||||
|
return '${firstName[0]}${lastName[0]}'.toUpperCase();
|
||||||
|
}
|
||||||
|
if (firstName.isNotEmpty && firstName.length >= 2) {
|
||||||
|
return firstName.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
if (username.isNotEmpty && username.length >= 2) {
|
||||||
|
return username.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get roleNames =>
|
||||||
|
authorities.map((a) => a.authority.replaceFirst('ROLE_', '')).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManagedUserAuthority {
|
||||||
|
final String authority;
|
||||||
|
|
||||||
|
ManagedUserAuthority({required this.authority});
|
||||||
|
|
||||||
|
factory ManagedUserAuthority.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ManagedUserAuthority(authority: json['authority'] as String? ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'authority': authority};
|
||||||
|
}
|
||||||
|
}
|
||||||
1126
lib/screens/users/screens/user_list_screen.dart
Normal file
1126
lib/screens/users/screens/user_list_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,7 @@ class _WorkspaceScreenState extends State<WorkspaceScreen>
|
|||||||
// reference it if needed.
|
// reference it if needed.
|
||||||
SharedPreferences.getInstance().then((prefs) {
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
prefs.setString('workspaceId', workspaces.first.id);
|
prefs.setString('workspaceId', workspaces.first.id);
|
||||||
|
prefs.setString('workspaceInitials', workspaces.first.name[0]);
|
||||||
});
|
});
|
||||||
_navigateToHome();
|
_navigateToHome();
|
||||||
} else if (workspaces.isEmpty) {
|
} else if (workspaces.isEmpty) {
|
||||||
|
|||||||
Reference in New Issue
Block a user