Mitra Bestie §1–§3: shell + Undangan + popup + chat polish
Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.
- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
chatRequestProvider.pendingInvites; row Terima delegates accept to
the notifier and ChatRequestOverlay owns nav (no double-push).
Perpanjang tab stubbed (empty state) until backend exposes
pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
(loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
_expectOtpPush flag — was stacking duplicate /otp pages on OTP
resend (see project-otp-nav-bug-fixed-2026-05-21)
Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
online/offline variants, undangan empty/populated/tolak states,
popup curhat-baru → accept → chat → ended banner, plus popup
dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
force_session_expires_at, delete_mitra_status_row,
customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
"fresh mitra with no status row" test setup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,33 @@ class ChatRequestErrorData extends ChatRequestData {
|
||||
const ChatRequestErrorData(this.message);
|
||||
}
|
||||
|
||||
/// Plain data holder for the Undangan list. The notifier exposes a derived
|
||||
/// `List<PendingInvite>` (currently-displayed incoming + queued) so screens
|
||||
/// outside the popup overlay (the Curhat-Baru tab) can render the same set
|
||||
/// of pending requests.
|
||||
///
|
||||
/// Stage 3 (2026-05-20): added to back the new `UndanganScreen` —
|
||||
/// `_pendingQueue` itself stays private.
|
||||
class PendingInvite {
|
||||
final String sessionId;
|
||||
final int? durationMinutes;
|
||||
final bool? isFreeTrial;
|
||||
final TopicSensitivity topicSensitivity;
|
||||
final DateTime? createdAt;
|
||||
final PairingRequestType requestType;
|
||||
final int? confirmationTimeoutSeconds;
|
||||
|
||||
const PendingInvite({
|
||||
required this.sessionId,
|
||||
this.durationMinutes,
|
||||
this.isFreeTrial,
|
||||
this.topicSensitivity = TopicSensitivity.regular,
|
||||
this.createdAt,
|
||||
this.requestType = PairingRequestType.general,
|
||||
this.confirmationTimeoutSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ChatRequest extends _$ChatRequest {
|
||||
WebSocketChannel? _channel;
|
||||
@@ -87,6 +114,46 @@ class ChatRequest extends _$ChatRequest {
|
||||
return current + _pendingQueue.length;
|
||||
}
|
||||
|
||||
/// Derived list of all pending invitations for list-style UIs (the
|
||||
/// Undangan tab). Combines the currently-displayed `ChatRequestIncomingData`
|
||||
/// (if any) plus every queued request, in display order.
|
||||
///
|
||||
/// Pure read view of `_pendingQueue` + `state` — no mutation, no async.
|
||||
/// Recomputed on every call so the result reflects the latest state at
|
||||
/// the moment of the call. Riverpod consumers must still `ref.watch`
|
||||
/// `chatRequestProvider` for rebuilds.
|
||||
List<PendingInvite> get pendingInvites {
|
||||
final out = <PendingInvite>[];
|
||||
final s = state;
|
||||
if (s is ChatRequestIncomingData) {
|
||||
out.add(PendingInvite(
|
||||
sessionId: s.sessionId,
|
||||
durationMinutes: s.durationMinutes,
|
||||
isFreeTrial: s.isFreeTrial,
|
||||
topicSensitivity: s.topicSensitivity,
|
||||
createdAt: s.createdAt,
|
||||
requestType: s.requestType,
|
||||
confirmationTimeoutSeconds: s.confirmationTimeoutSeconds,
|
||||
));
|
||||
}
|
||||
for (final q in _pendingQueue) {
|
||||
out.add(PendingInvite(
|
||||
sessionId: q['session_id'] as String,
|
||||
durationMinutes: q['duration_minutes'] as int?,
|
||||
isFreeTrial: q['is_free_trial'] as bool?,
|
||||
topicSensitivity:
|
||||
TopicSensitivity.fromString(q['topic_sensitivity'] as String?),
|
||||
createdAt: q['created_at'] != null
|
||||
? DateTime.tryParse(q['created_at'] as String)
|
||||
: null,
|
||||
requestType:
|
||||
PairingRequestType.fromString(q['request_type'] as String?),
|
||||
confirmationTimeoutSeconds: q['confirmation_timeout_seconds'] as int?,
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
ChatRequestData build() => const ChatRequestIdleData();
|
||||
|
||||
|
||||
@@ -4,9 +4,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../chat_request_notifier.dart';
|
||||
import '../../constants.dart';
|
||||
import '../../../router.dart';
|
||||
import '../../theme/halo_tokens.dart';
|
||||
import 'sensitivity_badge.dart';
|
||||
import 'sensitivity_theme.dart';
|
||||
|
||||
/// Centered modal overlay for incoming chat requests.
|
||||
///
|
||||
/// Visual restyle of the Figma `BestieIncomingPopup` (v5.jsx:129). Two
|
||||
/// variants are rendered from the same widget:
|
||||
/// - new (PairingRequestType.general) — pink-bordered card, 📨 emoji,
|
||||
/// 'Curhat Baru!' headline, 'Terima' button.
|
||||
/// - extend (PairingRequestType.returning) — amber-bordered card, ⚡ emoji,
|
||||
/// 'Perpanjang Curhat' headline, 'Terima Perpanjangan' button, '+N mnt'
|
||||
/// duration badge.
|
||||
///
|
||||
/// The accept/decline behavior, countdown timer, and stale handling are
|
||||
/// unchanged from the prior bottom-sheet implementation; only the visual
|
||||
/// layout differs.
|
||||
class ChatRequestOverlay extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
const ChatRequestOverlay({super.key, required this.child});
|
||||
@@ -18,27 +32,37 @@ class ChatRequestOverlay extends ConsumerStatefulWidget {
|
||||
class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animController;
|
||||
late final Animation<Offset> _slideAnimation;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
bool _visible = false;
|
||||
|
||||
// Returning-chat countdown. Server is the source of truth on auto-reject;
|
||||
// this is purely visual. When it hits 0 we dismiss the overlay and let the server's
|
||||
// chat_request_closed event (or stale state) take over.
|
||||
// this is purely visual. When it hits 0 we dismiss the overlay and let the
|
||||
// server's chat_request_closed event (or stale state) take over.
|
||||
Timer? _countdownTimer;
|
||||
int? _secondsRemaining;
|
||||
String? _countdownSessionId;
|
||||
|
||||
// Tracks the last sessionId we surfaced a 'taken by other Bestie' snackbar
|
||||
// for, so re-entering the same stale state doesn't queue duplicates.
|
||||
String? _lastStaleSnackbarSessionId;
|
||||
|
||||
// Snapshot of the most recently displayed incoming request. Kept so the
|
||||
// card can stay visible during ChatRequestAcceptingData (the notifier
|
||||
// drops the incoming payload on that transition) and render the spinner
|
||||
// inside the accept button instead of swapping to a placeholder card.
|
||||
ChatRequestIncomingData? _lastIncoming;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: HaloMotion.normal,
|
||||
);
|
||||
_scaleAnimation = CurvedAnimation(
|
||||
parent: _animController,
|
||||
curve: HaloMotion.ease,
|
||||
);
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,8 +94,8 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
if (!mounted) return;
|
||||
final remaining = (_secondsRemaining ?? 0) - 1;
|
||||
if (remaining <= 0) {
|
||||
// Auto-dismiss UI only — server fires the actual auto-reject and will follow up
|
||||
// with a chat_request_closed event. Do NOT call decline from the client here.
|
||||
// Auto-dismiss UI only — server fires the actual auto-reject and will
|
||||
// follow up with chat_request_closed. Do NOT call decline from here.
|
||||
setState(() => _secondsRemaining = 0);
|
||||
_stopCountdown();
|
||||
_hide();
|
||||
@@ -89,27 +113,13 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
}
|
||||
|
||||
void _maybeStartCountdownFor(ChatRequestIncomingData data) {
|
||||
final timeout = data.confirmationTimeoutSeconds;
|
||||
if (data.requestType == PairingRequestType.returning &&
|
||||
timeout != null &&
|
||||
timeout > 0) {
|
||||
// Restart only if this is a different session than the one we're already counting.
|
||||
if (_countdownSessionId != data.sessionId) {
|
||||
_startCountdown(data.sessionId, timeout);
|
||||
}
|
||||
} else {
|
||||
_stopCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSwipeDown(DragEndDetails details) {
|
||||
if (details.primaryVelocity != null && details.primaryVelocity! > 200) {
|
||||
final state = ref.read(chatRequestProvider);
|
||||
if (state is ChatRequestIncomingData) {
|
||||
ref.read(chatRequestProvider.notifier).ignore();
|
||||
} else if (state is ChatRequestStaleData) {
|
||||
ref.read(chatRequestProvider.notifier).acknowledgeStale();
|
||||
}
|
||||
// Both variants now show a live countdown to be consistent with Figma
|
||||
// ('30 detik' for new, '10 detik' for extend). Fall back to a sensible
|
||||
// default if the server didn't send a timeout.
|
||||
final timeout = data.confirmationTimeoutSeconds ??
|
||||
(data.requestType == PairingRequestType.returning ? 10 : 30);
|
||||
if (_countdownSessionId != data.sessionId) {
|
||||
_startCountdown(data.sessionId, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,22 +127,52 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(chatRequestProvider, (prev, next) {
|
||||
if (next is ChatRequestIncomingData) {
|
||||
_lastIncoming = next;
|
||||
_show();
|
||||
_maybeStartCountdownFor(next);
|
||||
} else if (next is ChatRequestStaleData) {
|
||||
// Stale message replaces the active request — kill any returning-chat countdown.
|
||||
_stopCountdown();
|
||||
_show();
|
||||
// Race-condition path: another Bestie grabbed it. Show a transient
|
||||
// snackbar instead of the modal card, and auto-advance the queue so
|
||||
// the next pending request (if any) takes over.
|
||||
if (next.reason == StaleReason.acceptedByOther &&
|
||||
_lastStaleSnackbarSessionId != next.sessionId) {
|
||||
_lastStaleSnackbarSessionId = next.sessionId;
|
||||
_hide();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.hideCurrentSnackBar();
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Pesan sudah diambil bestie lain 💛'),
|
||||
duration: Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
});
|
||||
// Auto-ack so the notifier moves on to the next queued invite.
|
||||
ref.read(chatRequestProvider.notifier).acknowledgeStale();
|
||||
} else {
|
||||
_show();
|
||||
}
|
||||
} else if (next is ChatRequestAcceptedData) {
|
||||
_hide();
|
||||
// Navigate to chat session
|
||||
final session = next.session;
|
||||
final sessionId = session['session_id'] as String? ?? session['id'] as String;
|
||||
final sessionId =
|
||||
session['session_id'] as String? ?? session['id'] as String;
|
||||
final router = ref.read(routerProvider);
|
||||
router.push('/chat/session/$sessionId', extra: {
|
||||
'customerName': session['customer_display_name'] as String? ?? 'Customer',
|
||||
'customerName':
|
||||
session['customer_display_name'] as String? ?? 'Customer',
|
||||
});
|
||||
} else if (next is ChatRequestAcceptingData) {
|
||||
// Keep the modal visible while the accept call is in flight; the
|
||||
// button content swaps to a spinner. _lastIncoming retains the
|
||||
// card content.
|
||||
} else {
|
||||
_lastIncoming = null;
|
||||
_hide();
|
||||
}
|
||||
});
|
||||
@@ -142,32 +182,44 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
child: Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
if (_visible) ...[
|
||||
// Semi-transparent dim
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () {}, // Block taps but don't dismiss
|
||||
if (_visible)
|
||||
Positioned.fill(
|
||||
child: FadeTransition(
|
||||
opacity: _animController,
|
||||
child: Container(color: Colors.black.withOpacity(0.3)),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Dim backdrop — taps are absorbed but DO NOT dismiss.
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.92, end: 1.0)
|
||||
.animate(_scaleAnimation),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s20,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: _buildContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Overlay content
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: GestureDetector(
|
||||
onVerticalDragEnd: _onSwipeDown,
|
||||
child: _buildContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -176,199 +228,419 @@ class _ChatRequestOverlayState extends ConsumerState<ChatRequestOverlay>
|
||||
final requestState = ref.watch(chatRequestProvider);
|
||||
|
||||
if (requestState is ChatRequestIncomingData) {
|
||||
return _buildActiveRequest(requestState);
|
||||
return _IncomingCard(
|
||||
data: requestState,
|
||||
secondsRemaining:
|
||||
_countdownSessionId == requestState.sessionId ? _secondsRemaining : null,
|
||||
accepting: false,
|
||||
onAccept: () => ref
|
||||
.read(chatRequestProvider.notifier)
|
||||
.accept(requestState.sessionId),
|
||||
onDecline: () => ref
|
||||
.read(chatRequestProvider.notifier)
|
||||
.decline(requestState.sessionId),
|
||||
);
|
||||
}
|
||||
if (requestState is ChatRequestAcceptingData) {
|
||||
// Keep the same card visible with both buttons disabled and the
|
||||
// accept button replaced by a spinner. Falls back to a minimal
|
||||
// placeholder card if for any reason we don't have the prior data
|
||||
// (e.g. cold start via setIncomingFromNotification).
|
||||
final prev = _lastIncoming;
|
||||
if (prev == null) return const _AcceptingCard();
|
||||
return _IncomingCard(
|
||||
data: prev,
|
||||
secondsRemaining:
|
||||
_countdownSessionId == prev.sessionId ? _secondsRemaining : null,
|
||||
accepting: true,
|
||||
onAccept: () {},
|
||||
onDecline: () {},
|
||||
);
|
||||
}
|
||||
if (requestState is ChatRequestStaleData) {
|
||||
return _buildStaleRequest(requestState);
|
||||
return _StaleCard(
|
||||
data: requestState,
|
||||
onAck: () =>
|
||||
ref.read(chatRequestProvider.notifier).acknowledgeStale(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActiveRequest(ChatRequestIncomingData data) {
|
||||
final durationText = data.isFreeTrial == true
|
||||
? 'Free Trial'
|
||||
: data.durationMinutes != null
|
||||
? '${data.durationMinutes} Menit'
|
||||
: '';
|
||||
/// Pink/amber bordered modal card for an active incoming request.
|
||||
class _IncomingCard extends StatelessWidget {
|
||||
const _IncomingCard({
|
||||
required this.data,
|
||||
required this.secondsRemaining,
|
||||
required this.accepting,
|
||||
required this.onAccept,
|
||||
required this.onDecline,
|
||||
});
|
||||
|
||||
final ChatRequestIncomingData data;
|
||||
final int? secondsRemaining;
|
||||
final bool accepting;
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onDecline;
|
||||
|
||||
bool get _isExtend => data.requestType == PairingRequestType.returning;
|
||||
|
||||
Color get _accent =>
|
||||
_isExtend ? HaloTokens.accentAmber : HaloTokens.brand;
|
||||
|
||||
String get _emoji => _isExtend ? '⚡' : '📨';
|
||||
|
||||
String get _headline => _isExtend ? 'Perpanjang Curhat' : 'Curhat Baru!';
|
||||
|
||||
String get _acceptLabel {
|
||||
if (_isExtend) {
|
||||
final mins = data.durationMinutes;
|
||||
return mins != null ? 'Terima · +$mins mnt' : 'Terima Perpanjangan';
|
||||
}
|
||||
return 'Terima';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSensitive = data.topicSensitivity == TopicSensitivity.sensitive;
|
||||
final theme = SensitivityTheme.of(data.topicSensitivity);
|
||||
final isReturning = data.requestType == PairingRequestType.returning;
|
||||
final showCountdown = isReturning &&
|
||||
_countdownSessionId == data.sessionId &&
|
||||
_secondsRemaining != null;
|
||||
final headlineText =
|
||||
isReturning ? 'Customer ingin chat lagi!' : 'Ada permintaan chat baru!';
|
||||
final subtitleText = isReturning
|
||||
? 'Seorang customer yang pernah chat denganmu ingin lanjut.'
|
||||
: 'Seorang customer ingin curhat denganmu.';
|
||||
final sensitivityTheme = SensitivityTheme.of(data.topicSensitivity);
|
||||
|
||||
final defaultCountdown = _isExtend ? 10 : 30;
|
||||
final remaining = secondsRemaining ?? defaultCountdown;
|
||||
final urgent = remaining <= 10;
|
||||
final countdownColor =
|
||||
urgent ? HaloTokens.danger : HaloTokens.inkSoft;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
border: isSensitive
|
||||
? Border(top: BorderSide(color: theme.badgeBg, width: 4))
|
||||
: null,
|
||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: _accent, width: 2),
|
||||
boxShadow: HaloShadows.card,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header: emoji + headline
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Drag handle
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isReturning ? Icons.replay_circle_filled : Icons.chat,
|
||||
size: 48,
|
||||
color: isReturning ? Colors.deepPurple : Colors.blue,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
headlineText,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
_emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (durationText.isNotEmpty)
|
||||
Text(
|
||||
'Durasi: $durationText',
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
if (isSensitive) ...[
|
||||
const SizedBox(height: 8),
|
||||
SensitivityBadge(sensitivity: data.topicSensitivity, fontSize: 12),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitleText,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
if (showCountdown) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_headline,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _isExtend ? HaloTokens.accentAmber : HaloTokens.brandDark,
|
||||
height: 1.15,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.timer_outlined, size: 16, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Konfirmasi dalam ${_secondsRemaining}s',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
ref.read(chatRequestProvider.notifier).decline(data.sessionId);
|
||||
},
|
||||
child: const Text('Tolak'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(chatRequestProvider.notifier).accept(data.sessionId);
|
||||
},
|
||||
child: const Text('Terima'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Geser ke bawah untuk mengabaikan',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStaleRequest(ChatRequestStaleData data) {
|
||||
final message = switch (data.reason) {
|
||||
StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh customer',
|
||||
StaleReason.acceptedByOther => 'Permintaan diterima oleh Bestie lain',
|
||||
StaleReason.expired => 'Permintaan kedaluwarsa',
|
||||
};
|
||||
|
||||
final icon = switch (data.reason) {
|
||||
StaleReason.cancelledByCustomer => Icons.cancel_outlined,
|
||||
StaleReason.acceptedByOther => Icons.people_outline,
|
||||
StaleReason.expired => Icons.timer_off_outlined,
|
||||
};
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Icon(icon, size: 48, color: Colors.orange),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(chatRequestProvider.notifier).acknowledgeStale();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
// Countdown line — color shifts to danger when <=10s.
|
||||
Text(
|
||||
'$remaining detik untuk respon',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: urgent ? FontWeight.w600 : FontWeight.w500,
|
||||
color: countdownColor,
|
||||
),
|
||||
),
|
||||
// Extend variant only: '+N mnt' duration badge.
|
||||
if (_isExtend && data.durationMinutes != null) ...[
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.accentAmberSoft,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: Text(
|
||||
'+${data.durationMinutes} mnt',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.accentAmber,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
// Optional details line (mode / customer name not in state yet —
|
||||
// per the Stage 3 finding, ChatRequestIncomingData doesn't carry
|
||||
// customer display name. Fall back to generic copy.)
|
||||
Text(
|
||||
_isExtend
|
||||
? 'Klien lama mau lanjutin curhat sama kamu.'
|
||||
: 'Pesan masuk dari user — siap dengerin?',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
if (isSensitive) ...[
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SensitivityBadge(
|
||||
sensitivity: data.topicSensitivity,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Show free-trial / duration meta as a subtle row (new variant only).
|
||||
if (!_isExtend &&
|
||||
(data.isFreeTrial == true || data.durationMinutes != null)) ...[
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
data.isFreeTrial == true
|
||||
? 'Durasi: Free Trial'
|
||||
: 'Durasi: ${data.durationMinutes} menit',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: HaloSpacing.s20),
|
||||
// Button row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: accepting ? null : onDecline,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: HaloTokens.inkSoft,
|
||||
backgroundColor: HaloTokens.brandSofter,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
side: BorderSide.none,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: HaloRadius.md,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: const Text('Tolak'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _AcceptButton(
|
||||
label: _acceptLabel,
|
||||
accent: _accent,
|
||||
accenting: accepting,
|
||||
onPressed: accepting ? null : onAccept,
|
||||
sensitivityTint:
|
||||
isSensitive ? sensitivityTheme.badgeBg : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled accept button. Renders pink (HaloTokens.brand) for the new variant
|
||||
/// and amber (HaloTokens.accentAmber) for the extend variant — same pattern
|
||||
/// as `_PrimaryAmberButton` in `undangan_screen.dart`. Kept inline rather
|
||||
/// than extending HaloButton, since adding a `backgroundColor` override to
|
||||
/// HaloButton would touch every existing call site.
|
||||
class _AcceptButton extends StatelessWidget {
|
||||
const _AcceptButton({
|
||||
required this.label,
|
||||
required this.accent,
|
||||
required this.accenting,
|
||||
required this.onPressed,
|
||||
required this.sensitivityTint,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color accent;
|
||||
final bool accenting;
|
||||
final VoidCallback? onPressed;
|
||||
final Color? sensitivityTint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: accent.withValues(alpha: 0.5),
|
||||
disabledForegroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.md),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: accenting
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// In-flight accept state — minimal card with spinner. The notifier drops
|
||||
/// the previous incoming data on transition to `ChatRequestAcceptingData`,
|
||||
/// so we render a small placeholder rather than mirror the full card.
|
||||
class _AcceptingCard extends StatelessWidget {
|
||||
const _AcceptingCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: HaloTokens.brand, width: 2),
|
||||
boxShadow: HaloShadows.card,
|
||||
),
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(HaloTokens.brand),
|
||||
),
|
||||
),
|
||||
SizedBox(width: HaloSpacing.s12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Menerima permintaan...',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restyled stale card for non-race stale reasons (cancelled / expired).
|
||||
/// The `acceptedByOther` race is handled via snackbar in the listener, so it
|
||||
/// never reaches this widget.
|
||||
class _StaleCard extends StatelessWidget {
|
||||
const _StaleCard({required this.data, required this.onAck});
|
||||
|
||||
final ChatRequestStaleData data;
|
||||
final VoidCallback onAck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final message = switch (data.reason) {
|
||||
StaleReason.cancelledByCustomer => 'Permintaan dibatalkan oleh klien',
|
||||
StaleReason.acceptedByOther => 'Permintaan diterima bestie lain',
|
||||
StaleReason.expired => 'Permintaan kedaluwarsa',
|
||||
};
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
border: Border.all(color: HaloTokens.border, width: 1),
|
||||
boxShadow: HaloShadows.card,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'⏱',
|
||||
style: TextStyle(fontSize: 32),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s20),
|
||||
ElevatedButton(
|
||||
onPressed: onAck,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: HaloTokens.brand,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: HaloRadius.md,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ class HaloTokens {
|
||||
static const Color danger = Color(0xFFD86B6B);
|
||||
static const Color border = Color(0xFFF0E4E8);
|
||||
|
||||
// Amber accent — used by the "Perpanjang Curhat" tab (BestieInvitesExtend
|
||||
// in figma-bestie/project/screens/v5.jsx).
|
||||
static const Color accentAmber = Color(0xFFD97706);
|
||||
static const Color accentAmberSoft = Color(0xFFFFE3A8);
|
||||
static const Color accentAmberBg = Color(0xFFFBEFE8);
|
||||
|
||||
// Font family names — must match the `family:` entries in pubspec.yaml.
|
||||
// Falls back to system fonts when the .ttf assets are not bundled.
|
||||
static const String fontDisplay = 'BricolageGrotesque';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
enum HaloButtonVariant { primary, secondary, ghost }
|
||||
enum HaloButtonVariant { primary, secondary, ghost, soft, dark }
|
||||
|
||||
enum HaloButtonSize { sm, md, lg }
|
||||
|
||||
@@ -93,6 +93,52 @@ class HaloButton extends StatelessWidget {
|
||||
child: child,
|
||||
);
|
||||
break;
|
||||
case HaloButtonVariant.soft:
|
||||
button = ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: HaloTokens.brandSofter,
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
disabledBackgroundColor: HaloTokens.brandSoft,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
elevation: 0,
|
||||
padding: padding,
|
||||
shape: shape,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
break;
|
||||
case HaloButtonVariant.dark:
|
||||
button = Container(
|
||||
decoration: disabled
|
||||
? null
|
||||
: const BoxDecoration(
|
||||
borderRadius: HaloRadius.pill,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x402A1820),
|
||||
offset: Offset(0, 6),
|
||||
blurRadius: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: HaloTokens.ink,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: HaloTokens.inkMuted,
|
||||
disabledForegroundColor: Colors.white70,
|
||||
elevation: 0,
|
||||
padding: padding,
|
||||
shape: shape,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (fullWidth) {
|
||||
|
||||
124
mitra_app/lib/core/theme/widgets/halo_orb.dart
Normal file
124
mitra_app/lib/core/theme/widgets/halo_orb.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
/// Decorative gradient blob avatar — the "Bestie" abstract avatar (not a face).
|
||||
///
|
||||
/// Ported from `mitra_app/figma-bestie/project/screens/primitives.jsx` (HBOrb).
|
||||
/// The JSX renders a CSS `radial-gradient` plus two soft white highlight blobs
|
||||
/// stacked over it. In Flutter we approximate with a `Stack`:
|
||||
/// - base: `Container` w/ `RadialGradient` from top-left
|
||||
/// - overlay 1: large soft white blob in upper-left (primary specular)
|
||||
/// - overlay 2: smaller dimmer blob in lower-right (secondary highlight,
|
||||
/// opposite the JSX which is also lower-right but very faint — keeping
|
||||
/// the same position for visual parity)
|
||||
/// - plus an outer drop shadow tinted by the seed's primary color.
|
||||
///
|
||||
/// Approximation note: CSS `filter: blur(6px)` on a sibling layer is emulated
|
||||
/// with low-opacity white circles. It reads as "soft highlight" at a glance
|
||||
/// without needing `ImageFilter.blur` (which would require ClipOval + BackdropFilter).
|
||||
class HaloOrb extends StatelessWidget {
|
||||
const HaloOrb({
|
||||
super.key,
|
||||
this.size = 120,
|
||||
this.seed = 0,
|
||||
});
|
||||
|
||||
/// Diameter in logical pixels.
|
||||
final double size;
|
||||
|
||||
/// 0–4 selects a deterministic color pair from the warm palette seeds.
|
||||
/// Out-of-range values are folded with modulo.
|
||||
final int seed;
|
||||
|
||||
/// Warm-palette seed table — mirrors `HBOrb` colors in primitives.jsx:68–73.
|
||||
static const List<List<Color>> _seeds = [
|
||||
[HaloTokens.brand, HaloTokens.accent],
|
||||
[HaloTokens.brandDark, HaloTokens.lilac],
|
||||
[HaloTokens.accent, HaloTokens.brand],
|
||||
[HaloTokens.lilac, HaloTokens.brand],
|
||||
[HaloTokens.mint, HaloTokens.brand],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pair = _seeds[seed.abs() % _seeds.length];
|
||||
final primary = pair[0];
|
||||
final secondary = pair[1];
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Base radial gradient + drop shadow.
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(-0.4, -0.4), // 30% / 30% in JSX
|
||||
radius: 0.85,
|
||||
colors: [primary, secondary],
|
||||
stops: const [0.0, 0.7],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: primary.withValues(alpha: 0.25),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Inner shadow approximation — darker rim, bottom-right.
|
||||
// Re-used the JSX `inset -8px -8px 20px rgba(0,0,0,0.12)` intent.
|
||||
IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(0.6, 0.6),
|
||||
radius: 0.85,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.0),
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
],
|
||||
stops: const [0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Top-left specular highlight (larger, brighter).
|
||||
Positioned(
|
||||
top: size * 0.12,
|
||||
left: size * 0.20,
|
||||
child: Container(
|
||||
width: size * 0.32,
|
||||
height: size * 0.24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom-right faint glint.
|
||||
Positioned(
|
||||
bottom: size * 0.14,
|
||||
right: size * 0.18,
|
||||
child: Container(
|
||||
width: size * 0.18,
|
||||
height: size * 0.14,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export 'halo_button.dart';
|
||||
export 'halo_orb.dart';
|
||||
|
||||
Reference in New Issue
Block a user