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/providers/splash_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:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
@@ -57,6 +58,9 @@ void main() async {
|
||||
ChangeNotifierProvider<WorkspaceProvider>(
|
||||
create: (_) => WorkspaceProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<UptimeProvider>(
|
||||
create: (_) => UptimeProvider(),
|
||||
),
|
||||
],
|
||||
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),
|
||||
"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:
|
||||
context.go('/history');
|
||||
break;
|
||||
case 3:
|
||||
context.go('/more');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +186,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
final String location = GoRouterState.of(context).uri.path;
|
||||
if (location.startsWith('/groups')) return 1;
|
||||
if (location.startsWith('/history')) return 2;
|
||||
if (location.startsWith('/more') || location.startsWith('/users') || location.startsWith('/uptime')) return 3;
|
||||
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/splash_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
|
||||
/// 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
|
||||
/// whose path starts with one of these strings will be redirected
|
||||
/// 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) {
|
||||
for (final prefix in _authProtectedRoutePrefixes) {
|
||||
@@ -225,6 +228,18 @@ GoRouter buildRouter() {
|
||||
path: '/profile',
|
||||
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(
|
||||
path: '/groups',
|
||||
builder: (context, state) => const GroupsScreen(),
|
||||
|
||||
@@ -67,6 +67,11 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||
_isLoadingZwG = true;
|
||||
});
|
||||
|
||||
// no need to proceed if not logged in
|
||||
if (!_isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch USD balance
|
||||
final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||
if (mounted) {
|
||||
|
||||
@@ -130,6 +130,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (prefs.getString("workspaceId") == null) {
|
||||
prefs.setString("workspaceId", Uuid().v4());
|
||||
}
|
||||
if (prefs.getString("userId") == null) {
|
||||
prefs.setString("userId", Uuid().v4());
|
||||
}
|
||||
|
||||
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||
}
|
||||
@@ -168,6 +171,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (prefs.getString("workspaceId") == null) {
|
||||
prefs.setString("workspaceId", Uuid().v4());
|
||||
}
|
||||
if (prefs.getString("userId") == null) {
|
||||
prefs.setString("userId", Uuid().v4());
|
||||
}
|
||||
|
||||
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) {
|
||||
AppSnackBar.showSuccess(context, 'Login successful!');
|
||||
context.go('/workspace');
|
||||
} else {
|
||||
AppSnackBar.showError(context, response.error ?? 'Login failed');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.isSuccess) {
|
||||
context.go('/workspace');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,7 +85,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
trailingRoute: '/onboarding/phone',
|
||||
trailingIcon: Icons.person_add_alt_1_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
bottomLinkRoute: '/workspace',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
|
||||
@@ -135,7 +135,9 @@ class ReceiptController extends ChangeNotifier {
|
||||
model.transaction = transaction;
|
||||
setLoading(false);
|
||||
return ApiResponse.success(transaction);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
print(e);
|
||||
print(s);
|
||||
setLoading(false);
|
||||
return ApiResponse.failure(
|
||||
"Problem fetching transaction data, are you connected to the internet?",
|
||||
|
||||
@@ -119,18 +119,23 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
}
|
||||
|
||||
Future<void> _fetchTransaction() async {
|
||||
final id =
|
||||
widget.transactionId ??
|
||||
transactionController.model.receiptData?["id"] ??
|
||||
receiptController.model.transaction?.id;
|
||||
try {
|
||||
final id =
|
||||
widget.transactionId ??
|
||||
transactionController.model.receiptData?["id"] ??
|
||||
receiptController.model.transaction?.id;
|
||||
|
||||
if (id == null) return;
|
||||
if (id == null) return;
|
||||
|
||||
final response = await receiptController.getReceiptData(id);
|
||||
if (response.isSuccess && response.data != null) {
|
||||
setState(() {
|
||||
_transaction = response.data;
|
||||
});
|
||||
final response = await receiptController.getReceiptData(id);
|
||||
if (response.isSuccess && response.data != null) {
|
||||
setState(() {
|
||||
_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.
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setString('workspaceId', workspaces.first.id);
|
||||
prefs.setString('workspaceInitials', workspaces.first.name[0]);
|
||||
});
|
||||
_navigateToHome();
|
||||
} else if (workspaces.isEmpty) {
|
||||
|
||||
Reference in New Issue
Block a user