cafebazaar_billing 1.2.0
cafebazaar_billing: ^1.2.0 copied to clipboard
cafebazaar_billing is a Flutter plugin that acts as a gate to Cafe Bazaar's native Android In-App Billing, so Flutter apps can sell products and subscriptions on the Cafe Bazaar store.
import 'package:cafebazaar_billing/cafebazaar_billing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const BillingDemoApp());
}
const Color _brandColor = Color(0xFF7B2FF2);
const Color _brandDark = Color(0xFF2D0B59);
class BillingDemoApp extends StatelessWidget {
const BillingDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Cafe Bazaar Billing Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _brandColor,
brightness: Brightness.light,
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(14)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
home: const DemoHomePage(),
);
}
}
enum _ConnStatus { disconnected, connecting, connected, failed }
enum _LogLevel { info, success, error }
class _LogEntry {
_LogEntry(this.time, this.message, this.level);
final DateTime time;
final String message;
final _LogLevel level;
}
class _Product {
const _Product({
required this.id,
required this.title,
required this.description,
required this.icon,
this.isSubscription = false,
});
final String id;
final String title;
final String description;
final IconData icon;
final bool isSubscription;
}
final List<_Product> _kCatalog = [
_Product(
id: 'gem_pack_small',
title: 'Bag of Gems',
description: '100 gems to boost your journey',
icon: Icons.diamond_outlined,
),
_Product(
id: 'gem_pack_large',
title: 'Chest of Gems',
description: '500 gems — best value bundle',
icon: Icons.inventory_2_outlined,
),
_Product(
id: 'remove_ads',
title: 'Remove Ads',
description: 'Enjoy the app ad-free forever',
icon: Icons.block_outlined,
),
_Product(
id: 'premium_monthly',
title: 'Premium Monthly',
description: 'All premium features, billed monthly',
icon: Icons.workspace_premium_outlined,
isSubscription: true,
),
_Product(
id: 'premium_yearly',
title: 'Premium Yearly',
description: 'Save more with the annual plan',
icon: Icons.stars_outlined,
isSubscription: true,
),
];
class DemoHomePage extends StatefulWidget {
const DemoHomePage({super.key});
@override
State<DemoHomePage> createState() => _DemoHomePageState();
}
class _DemoHomePageState extends State<DemoHomePage> {
final TextEditingController _rsaKeyController = TextEditingController();
final ScrollController _logScrollController = ScrollController();
_ConnStatus connection = _ConnStatus.disconnected;
bool busy = false;
Map<String, SkuDetails> skuMap = {};
List<PurchaseInfo> purchases = [];
List<PurchaseInfo> subscriptions = [];
final List<_LogEntry> logs = [];
bool get isConnected => connection == _ConnStatus.connected;
@override
void initState() {
super.initState();
_log('Welcome! Connect to Cafe Bazaar to begin.', _LogLevel.info);
}
@override
void dispose() {
if (isConnected) {
CafeBazaarBilling.disconnect();
}
_rsaKeyController.dispose();
_logScrollController.dispose();
super.dispose();
}
void _log(String message, _LogLevel level) {
setState(() {
logs.add(_LogEntry(DateTime.now(), message, level));
if (logs.length > 200) {
logs.removeAt(0);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
});
}
void _toast(String message, {bool error = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: error ? Colors.red.shade700 : _brandDark,
content: Text(message),
),
);
}
Future<void> _guarded(Future<void> Function() action) async {
if (busy) return;
setState(() => busy = true);
try {
await action();
} on PlatformException catch (e) {
_log('${e.code}: ${e.message ?? 'unknown error'}', _LogLevel.error);
_toast(_friendlyError(e.code), error: true);
} catch (e) {
_log('Unexpected error: $e', _LogLevel.error);
_toast('Unexpected error', error: true);
} finally {
if (mounted) setState(() => busy = false);
}
}
String _friendlyError(String code) {
switch (code) {
case 'PAYMENT_CONNECTION_IS_NOT_CONNECTED':
return 'Not connected. Connect to Bazaar first.';
case 'PURCHASE_CANCELLED':
return 'Purchase cancelled.';
case 'PURCHASE_FAILED':
return 'Purchase failed. Please try again.';
case 'FAILED_TO_BEGIN_FLOW':
return 'Could not open Bazaar. Is it installed?';
case 'CONSUME_FAILED':
return 'Consume failed. Refresh inventory and retry.';
default:
return 'Something went wrong ($code)';
}
}
Future<void> _connect() async {
final key = _rsaKeyController.text.trim();
setState(() => connection = _ConnStatus.connecting);
_log(
'Connecting${key.isEmpty ? ' without RSA key' : ''}...',
_LogLevel.info,
);
try {
await CafeBazaarBilling.connect(
key.isEmpty ? null : key,
onSucceed: () {
setState(() => connection = _ConnStatus.connected);
_log('Connected to Cafe Bazaar', _LogLevel.success);
_refreshStoreData();
},
onFailed: () {
setState(() => connection = _ConnStatus.failed);
_log('Connection failed', _LogLevel.error);
},
onDisconnected: () {
setState(() => connection = _ConnStatus.disconnected);
_log('Disconnected from Bazaar', _LogLevel.error);
_toast('Connection lost', error: true);
},
);
} on PlatformException catch (e) {
setState(() => connection = _ConnStatus.failed);
_log('Connect error [${e.code}] ${e.message}', _LogLevel.error);
}
}
Future<void> _disconnect() => _guarded(() async {
await CafeBazaarBilling.disconnect();
setState(() {
connection = _ConnStatus.disconnected;
skuMap = {};
purchases = [];
subscriptions = [];
});
_log('Disconnected', _LogLevel.info);
});
Future<void> _refreshStoreData() => _guarded(() async {
final results = await Future.wait([
CafeBazaarBilling.getInAppSkuDetails(
_kCatalog.where((p) => !p.isSubscription).map((p) => p.id).toList(),
),
CafeBazaarBilling.getSubscriptionSkuDetails(
_kCatalog.where((p) => p.isSubscription).map((p) => p.id).toList(),
),
CafeBazaarBilling.getAllPurchasedProducts(),
CafeBazaarBilling.getAllSubscribedProducts(),
]);
setState(() {
skuMap = {
for (final s in [
...results[0] as List<SkuDetails>,
...results[1] as List<SkuDetails>,
])
s.sku: s,
};
purchases = results[2] as List<PurchaseInfo>;
subscriptions = results[3] as List<PurchaseInfo>;
});
_log(
'Inventory: ${purchases.length} purchased, ${subscriptions.length} subscribed',
_LogLevel.success,
);
});
Future<void> _buy(_Product product) => _guarded(() async {
_log(
'${product.isSubscription ? 'Subscribing' : 'Buying'} "${product.title}"...',
_LogLevel.info,
);
final info = product.isSubscription
? await CafeBazaarBilling.subscribe(product.id)
: await CafeBazaarBilling.purchase(product.id);
_log(
'Success: ${info.productId} (state: ${info.purchaseState.name})',
_LogLevel.success,
);
_toast('Thank you! "${product.title}" is now yours.');
await _refreshStoreData();
});
Future<void> _consume(PurchaseInfo info) => _guarded(() async {
_log('Consuming ${info.productId}...', _LogLevel.info);
final ok = await CafeBazaarBilling.consume(info.purchaseToken);
_log(
ok ? 'Consumed ${info.productId}' : 'Consume returned false',
ok ? _LogLevel.success : _LogLevel.error,
);
await _refreshStoreData();
});
Future<void> _checkTrial() => _guarded(() async {
final trial = await CafeBazaarBilling.checkTrialSubscription();
final available = trial['isAvailable'] == true;
final days = trial['trialPeriodDays'];
_log(
'Trial available: $available (${days ?? '?'} days)',
available ? _LogLevel.success : _LogLevel.info,
);
if (!mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.card_giftcard),
SizedBox(width: 8),
Text('Free Trial'),
],
),
content: Text(
available
? 'You are eligible for a free trial.\nTrial length: $days day(s).'
: 'No free trial available for this account.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
});
Future<void> _showVersion() => _guarded(() async {
final v = await CafeBazaarBilling.getVersion();
_log('Native billing library version: $v', _LogLevel.info);
_toast('Native billing version: $v');
});
String _priceOf(_Product p) {
final details = skuMap[p.id];
return details?.price ??
(p.isSubscription ? 'subscription' : 'tap to load prices');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [_brandDark, Colors.white],
stops: [0.0, 0.45],
),
),
child: SafeArea(
bottom: false,
child: Column(
children: [
_Header(
connection: connection,
versionFuture: CafeBazaarBilling.getVersion(),
),
Expanded(
child: RefreshIndicator(
onRefresh: isConnected ? _refreshStoreData : () async {},
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_ConnectionCard(
controller: _rsaKeyController,
connection: connection,
busy: busy,
onConnect: _connect,
onDisconnect: _disconnect,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _QuickAction(
icon: Icons.card_giftcard_outlined,
label: 'Check Trial',
enabled: isConnected && !busy,
onTap: _checkTrial,
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickAction(
icon: Icons.refresh_rounded,
label: 'Refresh',
enabled: isConnected && !busy,
onTap: _refreshStoreData,
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickAction(
icon: Icons.info_outline_rounded,
label: 'Version',
enabled: true,
onTap: _showVersion,
),
),
],
),
const SizedBox(height: 24),
_SectionTitle('Store', Icons.storefront_rounded),
..._kCatalog.map(
(p) => _ProductTile(
product: p,
price: _priceOf(p),
enabled: isConnected && !busy,
onBuy: () => _buy(p),
),
),
const SizedBox(height: 24),
_SectionTitle('My Purchases', Icons.receipt_long_rounded),
if (purchases.isEmpty)
const _EmptyHint(text: 'No purchased items yet.')
else
...purchases.map(
(info) => _InventoryTile(
info: info,
trailingLabel: 'Consume',
trailingIcon: Icons.local_convenience_store_rounded,
enabled: isConnected && !busy,
onAction: () => _consume(info),
),
),
const SizedBox(height: 24),
_SectionTitle(
'My Subscriptions',
Icons.workspace_premium_rounded,
),
if (subscriptions.isEmpty)
const _EmptyHint(text: 'No active subscriptions yet.')
else
...subscriptions.map(
(info) => _InventoryTile(
info: info,
showStateChip: true,
enabled: false,
onAction: () {},
),
),
const SizedBox(height: 24),
_SectionTitle('Activity Log', Icons.terminal_rounded),
_LogConsole(
entries: logs,
scrollController: _logScrollController,
),
],
),
),
),
],
),
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.connection, required this.versionFuture});
final _ConnStatus connection;
final Future<String> versionFuture;
@override
Widget build(BuildContext context) {
const labels = {
_ConnStatus.disconnected: 'Disconnected',
_ConnStatus.connecting: 'Connecting...',
_ConnStatus.connected: 'Connected',
_ConnStatus.failed: 'Connection Failed',
};
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 12,
),
],
),
child: const Icon(
Icons.shopping_bag_rounded,
color: _brandColor,
size: 30,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'cafebazaar_billing',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
FutureBuilder<String>(
future: versionFuture,
builder: (context, snapshot) {
final v = snapshot.data;
return Text(
'In-App Billing Demo${v != null ? ' • native $v' : ''}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 13,
),
);
},
),
],
),
),
_StatusPill(
label: labels[connection]!,
connected: connection == _ConnStatus.connected,
connecting: connection == _ConnStatus.connecting,
),
],
),
);
}
}
class _StatusPill extends StatefulWidget {
const _StatusPill({
required this.label,
required this.connected,
required this.connecting,
});
final String label;
final bool connected;
final bool connecting;
@override
State<_StatusPill> createState() => _StatusPillState();
}
class _StatusPillState extends State<_StatusPill>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
value: 1,
);
if (widget.connecting) {
_controller.repeat(reverse: true);
}
}
@override
void didUpdateWidget(covariant _StatusPill oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.connecting && !_controller.isAnimating) {
_controller.repeat(reverse: true);
} else if (!widget.connecting && _controller.isAnimating) {
_controller.stop();
_controller.value = 1;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = widget.connected
? const Color(0xFF4CE08B)
: widget.connecting
? Colors.amberAccent
: Colors.redAccent.shade200;
return FadeTransition(
opacity: widget.connecting
? _controller.drive(Tween(begin: 0.55, end: 1.0))
: const AlwaysStoppedAnimation(1),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.6)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(
color: color,
animate: widget.connected || widget.connecting,
),
const SizedBox(width: 8),
Text(
widget.label,
style: const TextStyle(
color: Colors.white,
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
class _PulsingDot extends StatefulWidget {
const _PulsingDot({required this.color, required this.animate});
final Color color;
final bool animate;
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
value: 1,
);
if (widget.animate) {
_controller.repeat(reverse: true);
}
}
@override
void didUpdateWidget(covariant _PulsingDot oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.animate && !_controller.isAnimating) {
_controller.repeat(reverse: true);
} else if (!widget.animate && _controller.isAnimating) {
_controller.stop();
_controller.value = 1;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _controller.drive(Tween(begin: 0.75, end: 1.15)),
child: Container(
width: 9,
height: 9,
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
),
);
}
}
class _ConnectionCard extends StatelessWidget {
const _ConnectionCard({
required this.controller,
required this.connection,
required this.busy,
required this.onConnect,
required this.onDisconnect,
});
final TextEditingController controller;
final _ConnStatus connection;
final bool busy;
final VoidCallback onConnect;
final VoidCallback onDisconnect;
@override
Widget build(BuildContext context) {
final connected = connection == _ConnStatus.connected;
return Card(
elevation: 3,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.vpn_key_rounded,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
const Text(
'RSA Public Key',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const Spacer(),
Text(
'optional',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
const SizedBox(height: 6),
Text(
'Leave empty to disable local signature validation.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12.5),
),
const SizedBox(height: 12),
TextField(
controller: controller,
enabled: !connected,
maxLines: 2,
minLines: 1,
decoration: InputDecoration(
hintText: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...',
suffixIcon: connected
? const Icon(Icons.lock_outline, size: 18)
: null,
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed:
connected ||
busy ||
connection == _ConnStatus.connecting
? null
: onConnect,
icon: connection == _ConnStatus.connecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.link_rounded),
label: Text(connected ? 'Connected' : 'Connect'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: connected && !busy ? onDisconnect : null,
icon: const Icon(Icons.link_off_rounded),
label: const Text('Disconnect'),
),
),
],
),
],
),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.icon,
required this.label,
required this.enabled,
required this.onTap,
});
final IconData icon;
final String label;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: enabled ? 1 : 0.45,
child: Material(
color: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
size: 22,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text, this.icon);
final String text;
final IconData icon;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10, left: 4),
child: Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
);
}
}
class _ProductTile extends StatelessWidget {
const _ProductTile({
required this.product,
required this.price,
required this.enabled,
required this.onBuy,
});
final _Product product;
final String price;
final bool enabled;
final VoidCallback onBuy;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 1.5,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color:
(product.isSubscription
? const Color(0xFFFFB300)
: _brandColor)
.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
product.icon,
color: product.isSubscription
? const Color(0xFFB27A00)
: _brandColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
product.title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 6),
if (product.isSubscription)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(
0xFFFFB300,
).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'SUB',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: Color(0xFF9A6B00),
),
),
),
],
),
const SizedBox(height: 2),
Text(
product.description,
style: TextStyle(
fontSize: 12.5,
color: Colors.grey.shade600,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
price,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: enabled ? onBuy : null,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
child: Text(product.isSubscription ? 'Subscribe' : 'Buy'),
),
],
),
),
);
}
}
class _InventoryTile extends StatelessWidget {
const _InventoryTile({
required this.info,
required this.enabled,
required this.onAction,
this.trailingLabel,
this.trailingIcon,
this.showStateChip = false,
});
final PurchaseInfo info;
final bool enabled;
final VoidCallback onAction;
final String? trailingLabel;
final IconData? trailingIcon;
final bool showStateChip;
@override
Widget build(BuildContext context) {
final refunded = info.purchaseState == PurchaseState.REFUNDED;
return Card(
margin: const EdgeInsets.only(bottom: 10),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ListTile(
leading: CircleAvatar(
backgroundColor: (refunded ? Colors.red : Colors.green).withValues(
alpha: 0.15,
),
child: Icon(
refunded ? Icons.money_off_rounded : Icons.check_circle_rounded,
color: refunded ? Colors.red : Colors.green,
size: 22,
),
),
title: Text(
info.productId,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${info.purchaseState.name} • ${DateTime.fromMillisecondsSinceEpoch(info.purchaseTime)}\ntoken: ${info.purchaseToken.length > 26 ? '${info.purchaseToken.substring(0, 26)}…' : info.purchaseToken}',
style: TextStyle(
fontSize: 11.5,
color: Colors.grey.shade600,
height: 1.35,
),
),
isThreeLine: true,
trailing: showStateChip
? Chip(
avatar: const Icon(Icons.workspace_premium_rounded, size: 15),
label: const Text('Active', style: TextStyle(fontSize: 11)),
visualDensity: VisualDensity.compact,
)
: TextButton.icon(
onPressed: enabled ? onAction : null,
icon: Icon(trailingIcon ?? Icons.autorenew_rounded, size: 16),
label: Text(trailingLabel ?? 'Refresh'),
),
),
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
),
);
}
}
class _LogConsole extends StatelessWidget {
const _LogConsole({required this.entries, required this.scrollController});
final List<_LogEntry> entries;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return Container(
height: 190,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF16121F),
borderRadius: BorderRadius.circular(16),
),
child: entries.isEmpty
? Center(
child: Text(
'Logs will appear here...',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
)
: ListView.builder(
controller: scrollController,
itemCount: entries.length,
itemBuilder: (context, index) {
final e = entries[index];
final time =
'${e.time.hour.toString().padLeft(2, '0')}:${e.time.minute.toString().padLeft(2, '0')}:${e.time.second.toString().padLeft(2, '0')}';
final color = switch (e.level) {
_LogLevel.info => Colors.cyanAccent.shade100,
_LogLevel.success => Colors.greenAccent,
_LogLevel.error => Colors.redAccent.shade100,
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: RichText(
text: TextSpan(
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11.5,
),
children: [
TextSpan(
text: '$time ',
style: TextStyle(color: Colors.grey.shade600),
),
TextSpan(
text: e.message,
style: TextStyle(color: color),
),
],
),
),
);
},
),
);
}
}