Phase 3.1 WS2: FCM fallback Flutter + CC, unread badges, dynamic ping
- Control center: add mitra ping config UI (require ping toggle + interval)
- Mitra app StatusNotifier: honor require_ping and ping_interval_seconds
from API; skip heartbeat when ping not required
- Both apps: update notification services for FCM deep-linking
- mitra_app: handle chat_request (open_accept), session_closing
- client_app: handle session_closing, paired
- Unread badge providers:
- mitra_app: UnreadSessions provider (polls active-with-unread, badge
on active sessions button)
- client_app: UnreadCount provider (polls active-with-unread, badge
on _ActiveSessionCard)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
client_app/lib/core/chat/unread_notifier.dart
Normal file
50
client_app/lib/core/chat/unread_notifier.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../api/api_client_provider.dart';
|
||||||
|
|
||||||
|
part 'unread_notifier.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class UnreadCount extends _$UnreadCount {
|
||||||
|
Timer? _pollTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int build() {
|
||||||
|
_startPolling();
|
||||||
|
ref.onDispose(_stopPolling);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPolling() {
|
||||||
|
_stopPolling();
|
||||||
|
_fetchUnreadCount();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||||
|
_fetchUnreadCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPolling() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchUnreadCount() async {
|
||||||
|
try {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/api/client/chat/session/active-with-unread');
|
||||||
|
final data = response['data'];
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
state = data['unread_count'] as int? ?? 0;
|
||||||
|
} else {
|
||||||
|
state = 0;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void markRead() {
|
||||||
|
state = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh() => _fetchUnreadCount();
|
||||||
|
}
|
||||||
24
client_app/lib/core/chat/unread_notifier.g.dart
Normal file
24
client_app/lib/core/chat/unread_notifier.g.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'unread_notifier.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$unreadCountHash() => r'6a0b31b86ae616177f54346392d9675f916a7bec';
|
||||||
|
|
||||||
|
/// See also [UnreadCount].
|
||||||
|
@ProviderFor(UnreadCount)
|
||||||
|
final unreadCountProvider = NotifierProvider<UnreadCount, int>.internal(
|
||||||
|
UnreadCount.new,
|
||||||
|
name: r'unreadCountProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$unreadCountHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$UnreadCount = Notifier<int>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -84,12 +84,17 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _navigateFromMessage(Map<String, dynamic> data) {
|
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||||
|
if (_router == null) return;
|
||||||
final sessionId = data['session_id'] as String?;
|
final sessionId = data['session_id'] as String?;
|
||||||
if (sessionId == null || _router == null) return;
|
|
||||||
|
|
||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
if (type == 'chat_message' || type == 'chat_request') {
|
|
||||||
_router!.push('/chat/session/$sessionId');
|
if (type == 'session_closing' || type == 'session_expired') {
|
||||||
|
// Navigate to the chat session — closure UI will show
|
||||||
|
if (sessionId != null) {
|
||||||
|
_router!.push('/chat/session/$sessionId', extra: 'Bestie');
|
||||||
|
}
|
||||||
|
} else if ((type == 'chat_message' || type == 'paired') && sessionId != null) {
|
||||||
|
_router!.push('/chat/session/$sessionId', extra: 'Bestie');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/api/api_client_provider.dart';
|
import '../../core/api/api_client_provider.dart';
|
||||||
|
import '../../core/chat/unread_notifier.dart';
|
||||||
import '../../core/pairing/pairing_notifier.dart';
|
import '../../core/pairing/pairing_notifier.dart';
|
||||||
import '../chat/widgets/pricing_bottom_sheet.dart';
|
import '../chat/widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
@@ -133,15 +134,16 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActiveSessionCard extends StatelessWidget {
|
class _ActiveSessionCard extends ConsumerWidget {
|
||||||
final Map<String, dynamic> session;
|
final Map<String, dynamic> session;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ActiveSessionCard({required this.session, required this.onTap});
|
const _ActiveSessionCard({required this.session, required this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
||||||
|
final unreadCount = ref.watch(unreadCountProvider);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -152,9 +154,13 @@ class _ActiveSessionCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
Badge(
|
||||||
backgroundColor: Colors.green,
|
isLabelVisible: unreadCount > 0,
|
||||||
child: Icon(Icons.chat, color: Colors.white),
|
label: Text('$unreadCount'),
|
||||||
|
child: const CircleAvatar(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
child: Icon(Icons.chat, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
|
|||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 3.1: Mitra Ping Config
|
||||||
|
const fetchMitraPingConfig = async () => {
|
||||||
|
const res = await apiClient.get('/internal/config/mitra-ping')
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMitraPingConfig = async (data) => {
|
||||||
|
const res = await apiClient.patch('/internal/config/mitra-ping', data)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
const fetchEarlyEndConfig = async () => {
|
const fetchEarlyEndConfig = async () => {
|
||||||
const res = await apiClient.get('/internal/config/early-end')
|
const res = await apiClient.get('/internal/config/early-end')
|
||||||
return res.data.data
|
return res.data.data
|
||||||
@@ -101,7 +112,17 @@ export default function SettingsPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return <div>Loading...</div>
|
// Phase 3.1: Mitra Ping
|
||||||
|
const { data: mpData, isLoading: mpLoading } = useQuery({
|
||||||
|
queryKey: ['config-mitra-ping'],
|
||||||
|
queryFn: fetchMitraPingConfig,
|
||||||
|
})
|
||||||
|
const mpMutation = useMutation({
|
||||||
|
mutationFn: updateMitraPingConfig,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading) return <div>Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -215,6 +236,39 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Mitra Online Status (Ping)</h2>
|
||||||
|
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mpData?.require_ping ?? true}
|
||||||
|
onChange={e => mpMutation.mutate({ require_ping: e.target.checked })}
|
||||||
|
disabled={mpMutation.isPending}
|
||||||
|
/>
|
||||||
|
Wajibkan Mitra Ping (Heartbeat)
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||||
|
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<label>Interval Ping:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
value={mpData?.ping_interval_seconds ?? 15}
|
||||||
|
onChange={e => {
|
||||||
|
const val = parseInt(e.target.value, 10)
|
||||||
|
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val })
|
||||||
|
}}
|
||||||
|
disabled={mpMutation.isPending}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<span>detik</span>
|
||||||
|
</div>
|
||||||
|
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
54
mitra_app/lib/core/chat/unread_notifier.dart
Normal file
54
mitra_app/lib/core/chat/unread_notifier.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../api/api_client_provider.dart';
|
||||||
|
|
||||||
|
part 'unread_notifier.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class UnreadSessions extends _$UnreadSessions {
|
||||||
|
Timer? _pollTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, int> build() {
|
||||||
|
_startPolling();
|
||||||
|
ref.onDispose(_stopPolling);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPolling() {
|
||||||
|
_stopPolling();
|
||||||
|
_fetchUnreadCounts();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||||
|
_fetchUnreadCounts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPolling() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchUnreadCounts() async {
|
||||||
|
try {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/api/mitra/chat-requests/sessions/active-with-unread');
|
||||||
|
final sessions = response['data'] as List<dynamic>;
|
||||||
|
final counts = <String, int>{};
|
||||||
|
for (final s in sessions) {
|
||||||
|
final id = s['id'] as String;
|
||||||
|
final count = s['unread_count'] as int? ?? 0;
|
||||||
|
if (count > 0) counts[id] = count;
|
||||||
|
}
|
||||||
|
state = counts;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get totalUnread => state.values.fold(0, (a, b) => a + b);
|
||||||
|
|
||||||
|
void markSessionRead(String sessionId) {
|
||||||
|
state = {...state}..remove(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh() => _fetchUnreadCounts();
|
||||||
|
}
|
||||||
26
mitra_app/lib/core/chat/unread_notifier.g.dart
Normal file
26
mitra_app/lib/core/chat/unread_notifier.g.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'unread_notifier.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$unreadSessionsHash() => r'd2ff837f1e781e6aa624b3d3ca2befb0d1d258e8';
|
||||||
|
|
||||||
|
/// See also [UnreadSessions].
|
||||||
|
@ProviderFor(UnreadSessions)
|
||||||
|
final unreadSessionsProvider =
|
||||||
|
NotifierProvider<UnreadSessions, Map<String, int>>.internal(
|
||||||
|
UnreadSessions.new,
|
||||||
|
name: r'unreadSessionsProvider',
|
||||||
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$unreadSessionsHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$UnreadSessions = Notifier<Map<String, int>>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -84,12 +84,21 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _navigateFromMessage(Map<String, dynamic> data) {
|
static void _navigateFromMessage(Map<String, dynamic> data) {
|
||||||
|
if (_router == null) return;
|
||||||
final sessionId = data['session_id'] as String?;
|
final sessionId = data['session_id'] as String?;
|
||||||
if (sessionId == null || _router == null) return;
|
|
||||||
|
|
||||||
final type = data['type'] as String?;
|
final type = data['type'] as String?;
|
||||||
if (type == 'chat_message' || type == 'chat_request') {
|
final action = data['action'] as String?;
|
||||||
_router!.push('/chat/session/$sessionId');
|
|
||||||
|
if (type == 'chat_request' && action == 'open_accept') {
|
||||||
|
// Navigate to home where incoming request sheet will show
|
||||||
|
_router!.go('/home');
|
||||||
|
} else if (type == 'session_closing' || type == 'session_expired') {
|
||||||
|
// Navigate to the chat session closure screen
|
||||||
|
if (sessionId != null) {
|
||||||
|
_router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'});
|
||||||
|
}
|
||||||
|
} else if (type == 'chat_message' && sessionId != null) {
|
||||||
|
_router!.push('/chat/session/$sessionId', extra: {'customerName': 'Customer'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class StatusErrorData extends OnlineStatusData {
|
|||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class OnlineStatus extends _$OnlineStatus {
|
class OnlineStatus extends _$OnlineStatus {
|
||||||
Timer? _heartbeatTimer;
|
Timer? _heartbeatTimer;
|
||||||
|
bool _requirePing = true;
|
||||||
|
int _pingIntervalSeconds = 15;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
OnlineStatusData build() => const StatusInitialData();
|
OnlineStatusData build() => const StatusInitialData();
|
||||||
@@ -38,6 +40,8 @@ class OnlineStatus extends _$OnlineStatus {
|
|||||||
try {
|
try {
|
||||||
final response = await ref.read(apiClientProvider).get('/api/mitra/status');
|
final response = await ref.read(apiClientProvider).get('/api/mitra/status');
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
|
_requirePing = data['require_ping'] as bool? ?? true;
|
||||||
|
_pingIntervalSeconds = data['ping_interval_seconds'] as int? ?? 15;
|
||||||
state = StatusLoadedData(isOnline: data['is_online'] as bool);
|
state = StatusLoadedData(isOnline: data['is_online'] as bool);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const StatusLoadedData(isOnline: false);
|
state = const StatusLoadedData(isOnline: false);
|
||||||
@@ -48,7 +52,7 @@ class OnlineStatus extends _$OnlineStatus {
|
|||||||
state = const StatusLoadingData();
|
state = const StatusLoadingData();
|
||||||
try {
|
try {
|
||||||
await ref.read(apiClientProvider).post('/api/mitra/status/online');
|
await ref.read(apiClientProvider).post('/api/mitra/status/online');
|
||||||
_startHeartbeat();
|
if (_requirePing) _startHeartbeat();
|
||||||
state = const StatusLoadedData(isOnline: true);
|
state = const StatusLoadedData(isOnline: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
|
state = const StatusErrorData('Gagal mengubah status. Coba lagi.');
|
||||||
@@ -67,11 +71,11 @@ class OnlineStatus extends _$OnlineStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onAppPaused() {
|
void onAppPaused() {
|
||||||
_stopHeartbeat();
|
if (_requirePing) _stopHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onAppResumed() {
|
void onAppResumed() {
|
||||||
if (state is StatusLoadedData && (state as StatusLoadedData).isOnline) {
|
if (_requirePing && state is StatusLoadedData && (state as StatusLoadedData).isOnline) {
|
||||||
_startHeartbeat();
|
_startHeartbeat();
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
@@ -79,7 +83,7 @@ class OnlineStatus extends _$OnlineStatus {
|
|||||||
|
|
||||||
void _startHeartbeat() {
|
void _startHeartbeat() {
|
||||||
_stopHeartbeat();
|
_stopHeartbeat();
|
||||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
_heartbeatTimer = Timer.periodic(Duration(seconds: _pingIntervalSeconds), (_) {
|
||||||
_heartbeatTick();
|
_heartbeatTick();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/status/status_notifier.dart';
|
import '../../core/status/status_notifier.dart';
|
||||||
import '../../core/chat/chat_request_notifier.dart';
|
import '../../core/chat/chat_request_notifier.dart';
|
||||||
|
import '../../core/chat/unread_notifier.dart';
|
||||||
import '../chat/widgets/incoming_request_sheet.dart';
|
import '../chat/widgets/incoming_request_sheet.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
@@ -159,16 +160,23 @@ class _StatusToggle extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActiveSessionsButton extends StatelessWidget {
|
class _ActiveSessionsButton extends ConsumerWidget {
|
||||||
const _ActiveSessionsButton();
|
const _ActiveSessionsButton();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final unreadCounts = ref.watch(unreadSessionsProvider);
|
||||||
|
final totalUnread = unreadCounts.values.fold(0, (a, b) => a + b);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
leading: Badge(
|
||||||
|
isLabelVisible: totalUnread > 0,
|
||||||
|
label: Text('$totalUnread'),
|
||||||
|
child: const Icon(Icons.chat_bubble_outline),
|
||||||
|
),
|
||||||
title: const Text('Sesi Aktif'),
|
title: const Text('Sesi Aktif'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => context.push('/sessions'),
|
onTap: () => context.push('/sessions'),
|
||||||
|
|||||||
Reference in New Issue
Block a user