feat(shopinbit): add ShopInBit ticket management views
What changed, and why it matters
This commit adds new screens for managing ShopInBit support tickets inside the Stack Wallet app. It lets users view ticket status, read messages, and send replies. The code also renders images embedded in messages by decoding base64 data. There is no clear security bug, but the image decoding and HTML stripping are done with simple regular expressions, which could be fragile if a malicious server sends unusual message content. The commit itself is a feature addition, not a fix.
Treat this as a routine feature commit, not a security patch. As a defensive measure, review the message-rendering code: use a robust HTML sanitizer for agent messages, validate base64 image sizes and formats before decoding, and avoid silently swallowing all exceptions. Consider whether remote HTML should be rendered as plain text only.
Security signals we found
Base64 image data from remote messages is decoded and rendered directly with Image.memory
HTML content from remote messages is processed with regex-based tag stripping rather than a proper HTML parser/sanitizer
Network and decode exceptions are caught and silently ignored, which could hide malformed or malicious payloads
No visible output encoding or content-security policy for rendered message text
Evidence from the diff
The patch introduces three new Flutter UI pages (order-created confirmation, ticket list, ticket detail/chat) and wires them into the route generator. The ticket detail view fetches messages and status from ShopInBitService, persists them via MainDB, and allows sending replies. Message rendering uses a regex to find inline base64 tags, decodes them with base64Decode, and strips remaining HTML tags with another regex. No input validation, sanitization beyond tag stripping, or content-security measures are visible in the diff. Errors from network and decode operations are silently swallowed.
Changed components
lib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages/shopinbit/shopinbit_tickets_view.dartlib/pages/shopinbit/shopinbit_order_created.dartlib/route_generator.dartInspect captured patch +1072 / −0
diff --git a/lib/pages/shopinbit/shopinbit_order_created.dart b/lib/pages/shopinbit/shopinbit_order_created.dart
new file mode 100644
index 0000000..92576c3
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_order_created.dart
@@ -0,0 +1,190 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/assets.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../widgets/background.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/desktop_dialog.dart';
+import '../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/desktop/secondary_button.dart';
+import '../../widgets/rounded_white_container.dart';
+import 'shopinbit_ticket_detail.dart';
+
+class ShopInBitOrderCreated extends StatelessWidget {
+ const ShopInBitOrderCreated({super.key, required this.model});
+
+ static const String routeName = "/shopInBitOrderCreated";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final content = Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Spacer(),
+ SvgPicture.asset(
+ Assets.svg.checkCircle,
+ width: isDesktop ? 64 : 48,
+ height: isDesktop ? 64 : 48,
+ color: Theme.of(context).extension<StackColors>()!.accentColorGreen,
+ ),
+ SizedBox(height: isDesktop ? 24 : 16),
+ Text(
+ "Order created!",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(height: isDesktop ? 16 : 8),
+ Text(
+ "Your request has been submitted.",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ textAlign: TextAlign.center,
+ ),
+ SizedBox(height: isDesktop ? 32 : 24),
+ RoundedWhiteContainer(
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Ticket ID",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ Text(
+ model.ticketId ?? "N/A",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ ],
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Status",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ Text(
+ "Pending review",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const Spacer(),
+ PrimaryButton(
+ label: "View ticket",
+ onPressed: () {
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+
+ builder: (_) => ShopInBitTicketDetail(model: model),
+ );
+ } else {
+ Navigator.of(
+ context,
+ ).pushNamed(ShopInBitTicketDetail.routeName, arguments: model);
+ }
+ },
+ ),
+ SizedBox(height: isDesktop ? 16 : 12),
+ SecondaryButton(
+ label: "Back to services",
+ onPressed: () {
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ } else {
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ }
+ },
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 550,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "ShopInBit",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("ShopInBit", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 32,
+ ),
+ child: IntrinsicHeight(child: content),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
new file mode 100644
index 0000000..e5c13c4
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -0,0 +1,515 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+
+import '../../db/isar/main_db.dart';
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../widgets/background.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/desktop_dialog.dart';
+import '../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/rounded_white_container.dart';
+import 'shopinbit_offer_view.dart';
+
+class ShopInBitTicketDetail extends StatefulWidget {
+ const ShopInBitTicketDetail({super.key, required this.model});
+
+ static const String routeName = "/shopInBitTicketDetail";
+
+ final ShopInBitOrderModel model;
+
+ @override
+ State<ShopInBitTicketDetail> createState() => _ShopInBitTicketDetailState();
+}
+
+class _ShopInBitTicketDetailState extends State<ShopInBitTicketDetail> {
+ late final TextEditingController _messageController;
+
+ String _statusLabel(ShopInBitOrderStatus status) {
+ switch (status) {
+ case ShopInBitOrderStatus.pending:
+ return "Pending";
+ case ShopInBitOrderStatus.reviewing:
+ return "Under review";
+ case ShopInBitOrderStatus.offerAvailable:
+ return "Offer available";
+ case ShopInBitOrderStatus.accepted:
+ return "Accepted";
+ case ShopInBitOrderStatus.paymentPending:
+ return "Awaiting payment";
+ case ShopInBitOrderStatus.paid:
+ return "Paid";
+ case ShopInBitOrderStatus.shipping:
+ return "Shipping";
+ case ShopInBitOrderStatus.delivered:
+ return "Delivered";
+ case ShopInBitOrderStatus.closed:
+ return "Closed";
+ case ShopInBitOrderStatus.cancelled:
+ return "Cancelled";
+ case ShopInBitOrderStatus.refunded:
+ return "Refunded";
+ }
+ }
+
+ Color _statusColor(BuildContext context, ShopInBitOrderStatus status) {
+ switch (status) {
+ case ShopInBitOrderStatus.delivered:
+ return Theme.of(context).extension<StackColors>()!.accentColorGreen;
+ case ShopInBitOrderStatus.offerAvailable:
+ return Theme.of(context).extension<StackColors>()!.accentColorBlue;
+ case ShopInBitOrderStatus.pending:
+ case ShopInBitOrderStatus.reviewing:
+ return Theme.of(context).extension<StackColors>()!.accentColorYellow;
+ case ShopInBitOrderStatus.closed:
+ case ShopInBitOrderStatus.cancelled:
+ case ShopInBitOrderStatus.refunded:
+ return Theme.of(context).extension<StackColors>()!.textSubtitle1;
+ default:
+ return Theme.of(context).extension<StackColors>()!.accentColorDark;
+ }
+ }
+
+ bool _sending = false;
+ bool _loading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _messageController = TextEditingController();
+ if (widget.model.apiTicketId != 0) {
+ _loadFromApi();
+ }
+ }
+
+ @override
+ void dispose() {
+ _messageController.dispose();
+ super.dispose();
+ }
+
+ Future<void> _loadFromApi() async {
+ setState(() => _loading = true);
+ try {
+ final client = ShopInBitService.instance.client;
+ final id = widget.model.apiTicketId;
+
+ final messagesResp = await client.getMessages(id);
+ final statusResp = await client.getTicketStatus(id);
+
+ if (!messagesResp.hasError && messagesResp.value != null) {
+ final apiMessages = messagesResp.value!;
+ widget.model.clearMessages();
+ for (final m in apiMessages) {
+ widget.model.addMessage(
+ ShopInBitMessage(
+ text: m.content,
+ timestamp: m.timestamp,
+ isFromUser: !m.fromAgent,
+ ),
+ );
+ }
+ }
+
+ if (!statusResp.hasError && statusResp.value != null) {
+ widget.model.status = ShopInBitOrderModel.statusFromTicketState(
+ statusResp.value!.state,
+ );
+ }
+
+ unawaited(
+ MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()),
+ );
+ } catch (_) {
+ // Silently fall back to local data
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ Future<void> _sendMessage() async {
+ final text = _messageController.text.trim();
+ if (text.isEmpty || _sending) return;
+
+ setState(() => _sending = true);
+ _messageController.clear();
+
+ // Add optimistic local message
+ widget.model.addMessage(
+ ShopInBitMessage(text: text, timestamp: DateTime.now(), isFromUser: true),
+ );
+ setState(() {});
+
+ try {
+ if (widget.model.apiTicketId != 0) {
+ await ShopInBitService.instance.client.sendMessage(
+ widget.model.apiTicketId,
+ text,
+ );
+ // Reload messages from API to get accurate state
+ await _loadFromApi();
+ }
+ unawaited(
+ MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()),
+ );
+ } catch (_) {
+ // Keep optimistic local message
+ } finally {
+ if (mounted) setState(() => _sending = false);
+ }
+ }
+
+ String _formatTime(DateTime dt) {
+ final hour = dt.hour.toString().padLeft(2, '0');
+ final minute = dt.minute.toString().padLeft(2, '0');
+ return "$hour:$minute";
+ }
+
+ static final _imgTagRegex = RegExp(
+ r'<img[^>]+src="data:image/[^;]+;base64,([^"]+)"[^>]*/?>',
+ caseSensitive: false,
+ );
+
+ List<Widget> _buildMessageContent(
+ String html,
+ bool isDesktop,
+ Color? textColor,
+ ) {
+ final textStyle =
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(color: textColor);
+
+ final widgets = <Widget>[];
+ var lastEnd = 0;
+
+ for (final match in _imgTagRegex.allMatches(html)) {
+ // Add any text before this <img>
+ if (match.start > lastEnd) {
+ final textChunk = html
+ .substring(lastEnd, match.start)
+ .replaceAll(RegExp(r'</?div>'), '')
+ .replaceAll(RegExp(r'<br\s*/?>'), '\n')
+ .replaceAll(RegExp(r'<[^>]*>'), '')
+ .trim();
+ if (textChunk.isNotEmpty) {
+ widgets.add(Text(textChunk, style: textStyle));
+ }
+ }
+
+ // Decode and render the image
+ try {
+ final bytes = base64Decode(match.group(1)!);
+ widgets.add(
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Image.memory(bytes),
+ ),
+ );
+ } catch (_) {
+ // Skip malformed images
+ }
+
+ lastEnd = match.end;
+ }
+
+ // Add any remaining text after the last <img>
+ if (lastEnd < html.length) {
+ final textChunk = html
+ .substring(lastEnd)
+ .replaceAll(RegExp(r'</?div>'), '')
+ .replaceAll(RegExp(r'<br\s*/?>'), '\n')
+ .replaceAll(RegExp(r'<[^>]*>'), '')
+ .trim();
+ if (textChunk.isNotEmpty) {
+ widgets.add(Text(textChunk, style: textStyle));
+ }
+ }
+
+ if (widgets.isEmpty) {
+ widgets.add(Text('', style: textStyle));
+ }
+
+ return widgets;
+ }
+
+ Widget _chatBubble(ShopInBitMessage message, bool isDesktop) {
+ final textColor = message.isFromUser ? Colors.white : null;
+
+ return Align(
+ alignment: message.isFromUser
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
+ child: Container(
+ constraints: BoxConstraints(maxWidth: isDesktop ? 380 : 260),
+ margin: const EdgeInsets.symmetric(vertical: 4),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: message.isFromUser
+ ? Theme.of(context).extension<StackColors>()!.accentColorBlue
+ : Theme.of(context).extension<StackColors>()!.popupBG,
+ borderRadius: BorderRadius.only(
+ topLeft: const Radius.circular(12),
+ topRight: const Radius.circular(12),
+ bottomLeft: message.isFromUser
+ ? const Radius.circular(12)
+ : Radius.zero,
+ bottomRight: message.isFromUser
+ ? Radius.zero
+ : const Radius.circular(12),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ if (message.isFromUser)
+ Text(
+ message.text,
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(color: textColor),
+ )
+ else
+ ..._buildMessageContent(message.text, isDesktop, textColor),
+ const SizedBox(height: 4),
+ Text(
+ _formatTime(message.timestamp),
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ fontSize: 10,
+ color: message.isFromUser
+ ? Colors.white.withOpacity(0.7)
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1
+ .withOpacity(0.7),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ final model = widget.model;
+
+ final statusBar = RoundedWhiteContainer(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ model.ticketId ?? "Ticket",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ color: _statusColor(context, model.status).withOpacity(0.2),
+ ),
+ child: Text(
+ _statusLabel(model.status),
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(color: _statusColor(context, model.status)),
+ ),
+ ),
+ ],
+ ),
+ );
+
+ final offerBanner = model.status == ShopInBitOrderStatus.offerAvailable
+ ? Padding(
+ padding: EdgeInsets.only(bottom: isDesktop ? 16 : 12),
+ child: RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Offer available",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ "${model.offerProductName ?? 'Item'} \u2014 "
+ "${model.offerPrice ?? '0'} EUR",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ SizedBox(height: isDesktop ? 12 : 8),
+ PrimaryButton(
+ label: "Review offer",
+ onPressed: () {
+ if (isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+
+ builder: (_) => ShopInBitOfferView(model: model),
+ );
+ } else {
+ Navigator.of(context).pushNamed(
+ ShopInBitOfferView.routeName,
+ arguments: model,
+ );
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ )
+ : const SizedBox.shrink();
+
+ final chatArea = Expanded(
+ child: Stack(
+ children: [
+ ListView.builder(
+ reverse: true,
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ itemCount: model.messages.length,
+ itemBuilder: (context, index) {
+ final message = model.messages[model.messages.length - 1 - index];
+ return _chatBubble(message, isDesktop);
+ },
+ ),
+ if (_loading)
+ const Center(
+ child: SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ ),
+ ],
+ ),
+ );
+
+ final inputBar = Container(
+ padding: EdgeInsets.all(isDesktop ? 16 : 8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _messageController,
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraSmall(context)
+ : STextStyles.field(context))
+ .copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textDark,
+ ),
+ decoration: InputDecoration(
+ hintText: "Type a message...",
+ hintStyle: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.fieldLabel(context),
+ border: InputBorder.none,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ ),
+ onSubmitted: (_) => _sendMessage(),
+ ),
+ ),
+ IconButton(
+ onPressed: _sendMessage,
+ icon: Icon(
+ Icons.send,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorBlue,
+ ),
+ ),
+ ],
+ ),
+ );
+
+ final body = Column(
+ children: [
+ statusBar,
+ offerBanner,
+ chatArea,
+ SizedBox(height: isDesktop ? 12 : 8),
+ inputBar,
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 600,
+ maxHeight: 650,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text("Ticket", style: STextStyles.desktopH3(context)),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 8,
+ ),
+ child: body,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text(
+ model.ticketId ?? "Ticket",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(
+ child: Padding(padding: const EdgeInsets.all(16), child: body),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
new file mode 100644
index 0000000..09b67d8
--- /dev/null
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -0,0 +1,337 @@
+import 'package:flutter/material.dart';
+
+import '../../db/isar/main_db.dart';
+import '../../models/shopinbit/shopinbit_order_model.dart';
+import '../../services/shopinbit/shopinbit_service.dart';
+import '../../themes/stack_colors.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../widgets/background.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/desktop/desktop_dialog.dart';
+import '../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../widgets/rounded_white_container.dart';
+import 'shopinbit_ticket_detail.dart';
+
+class ShopInBitTicketsView extends StatefulWidget {
+ const ShopInBitTicketsView({super.key});
+
+ static const String routeName = "/shopInBitTickets";
+
+ @override
+ State<ShopInBitTicketsView> createState() => _ShopInBitTicketsViewState();
+}
+
+class _ShopInBitTicketsViewState extends State<ShopInBitTicketsView> {
+ List<ShopInBitOrderModel> _tickets = [];
+ bool _syncing = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadLocal();
+ _syncFromApi();
+ }
+
+ void _loadLocal() {
+ _tickets = MainDB.instance
+ .getShopInBitTickets()
+ .map(ShopInBitOrderModel.fromIsarTicket)
+ .toList();
+ }
+
+ Future<void> _syncFromApi() async {
+ setState(() => _syncing = true);
+ try {
+ final service = ShopInBitService.instance;
+ final customerKey = await service.ensureCustomerKey();
+ final resp = await service.client.getTicketsByCustomer(customerKey);
+
+ if (resp.hasError || resp.value == null) return;
+
+ for (final ref in resp.value!) {
+ final localIdx = _tickets.indexWhere((t) => t.apiTicketId == ref.id);
+ if (localIdx < 0) continue;
+
+ // Skip API calls for terminal tickets; they can still be
+ // refreshed on-demand when the user opens the detail view.
+ final localStatus = _tickets[localIdx].status;
+ if (localStatus == ShopInBitOrderStatus.closed ||
+ localStatus == ShopInBitOrderStatus.cancelled ||
+ localStatus == ShopInBitOrderStatus.refunded) {
+ continue;
+ }
+
+ final statusResp = await service.client.getTicketStatus(ref.id);
+ if (statusResp.hasError || statusResp.value == null) continue;
+
+ _tickets[localIdx].status = ShopInBitOrderModel.statusFromTicketState(
+ statusResp.value!.state,
+ );
+
+ final msgsResp = await service.client.getMessages(ref.id);
+ if (!msgsResp.hasError && msgsResp.value != null) {
+ _tickets[localIdx].clearMessages();
+ for (final m in msgsResp.value!) {
+ _tickets[localIdx].addMessage(
+ ShopInBitMessage(
+ text: m.content,
+ timestamp: m.timestamp,
+ isFromUser: !m.fromAgent,
+ ),
+ );
+ }
+ }
+
+ await MainDB.instance.putShopInBitTicket(
+ _tickets[localIdx].toIsarTicket(),
+ );
+ }
+ } catch (_) {
+ // Fall back to local data
+ } finally {
+ if (mounted) {
+ _loadLocal();
+ setState(() => _syncing = false);
+ }
+ }
+ }
+
+ String _statusLabel(ShopInBitOrderStatus status) {
+ switch (status) {
+ case ShopInBitOrderStatus.pending:
+ return "Pending";
+ case ShopInBitOrderStatus.reviewing:
+ return "Under review";
+ case ShopInBitOrderStatus.offerAvailable:
+ return "Offer available";
+ case ShopInBitOrderStatus.accepted:
+ return "Accepted";
+ case ShopInBitOrderStatus.paymentPending:
+ return "Awaiting payment";
+ case ShopInBitOrderStatus.paid:
+ return "Paid";
+ case ShopInBitOrderStatus.shipping:
+ return "Shipping";
+ case ShopInBitOrderStatus.delivered:
+ return "Delivered";
+ case ShopInBitOrderStatus.closed:
+ return "Closed";
+ case ShopInBitOrderStatus.cancelled:
+ return "Cancelled";
+ case ShopInBitOrderStatus.refunded:
+ return "Refunded";
+ }
+ }
+
+ Color _statusColor(BuildContext context, ShopInBitOrderStatus status) {
+ switch (status) {
+ case ShopInBitOrderStatus.delivered:
+ return Theme.of(context).extension<StackColors>()!.accentColorGreen;
+ case ShopInBitOrderStatus.offerAvailable:
+ return Theme.of(context).extension<StackColors>()!.accentColorBlue;
+ case ShopInBitOrderStatus.pending:
+ case ShopInBitOrderStatus.reviewing:
+ return Theme.of(context).extension<StackColors>()!.accentColorYellow;
+ case ShopInBitOrderStatus.closed:
+ case ShopInBitOrderStatus.cancelled:
+ case ShopInBitOrderStatus.refunded:
+ return Theme.of(context).extension<StackColors>()!.textSubtitle1;
+ default:
+ return Theme.of(context).extension<StackColors>()!.accentColorDark;
+ }
+ }
+
+ String _categoryLabel(ShopInBitCategory? category) {
+ switch (category) {
+ case ShopInBitCategory.concierge:
+ return "Concierge";
+ case ShopInBitCategory.travel:
+ return "Travel";
+ case ShopInBitCategory.car:
+ return "Car";
+ case null:
+ return "";
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+
+ final list = _tickets.isEmpty
+ ? Center(
+ child: Text(
+ _syncing ? "Loading tickets..." : "No tickets yet",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ )
+ : ListView.separated(
+ shrinkWrap: true,
+ itemCount: _tickets.length,
+ separatorBuilder: (_, __) => SizedBox(height: isDesktop ? 16 : 12),
+ itemBuilder: (context, index) {
+ final ticket = _tickets[index];
+ return GestureDetector(
+ onTap: () {
+ if (isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitTicketDetail(model: ticket),
+ );
+ } else {
+ Navigator.of(context).pushNamed(
+ ShopInBitTicketDetail.routeName,
+ arguments: ticket,
+ );
+ }
+ },
+ child: RoundedWhiteContainer(
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ ticket.ticketId ?? "N/A",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ color: _statusColor(
+ context,
+ ticket.status,
+ ).withOpacity(0.2),
+ ),
+ child: Text(
+ _statusLabel(ticket.status),
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(
+ context,
+ ))
+ .copyWith(
+ color: _statusColor(
+ context,
+ ticket.status,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ "${_categoryLabel(ticket.category)} \u2022 "
+ "${ticket.requestDescription}",
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(
+ context,
+ ).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: isDesktop ? 16 : 8),
+ Icon(
+ Icons.chevron_right,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+
+ final content = Stack(
+ children: [
+ list,
+ if (_syncing)
+ const Center(
+ child: SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ ),
+ ],
+ );
+
+ if (isDesktop) {
+ return DesktopDialog(
+ maxWidth: 580,
+ maxHeight: 550,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "My tickets",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 32,
+ vertical: 16,
+ ),
+ child: content,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ title: Text("My tickets", style: STextStyles.navBarTitle(context)),
+ ),
+ body: SafeArea(
+ child: Padding(padding: const EdgeInsets.all(16), child: content),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index cc4e731..21f1cb5 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -89,6 +89,9 @@ import 'pages/shopinbit/shopinbit_step_1.dart';
import 'pages/shopinbit/shopinbit_step_2.dart';
import 'pages/shopinbit/shopinbit_step_3.dart';
import 'pages/shopinbit/shopinbit_step_4.dart';
+import 'pages/shopinbit/shopinbit_ticket_detail.dart';
+import 'pages/shopinbit/shopinbit_tickets_view.dart';
+import 'pages/shopinbit/shopinbit_order_created.dart';
import 'pages/namecoin_names/buy_domain_view.dart';
import 'pages/namecoin_names/confirm_name_transaction_view.dart';
import 'pages/namecoin_names/manage_domain_view.dart';
@@ -1092,6 +1095,33 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case ShopInBitOrderCreated.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitOrderCreated(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case ShopInBitTicketsView.routeName:
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => const ShopInBitTicketsView(),
+ settings: RouteSettings(name: settings.name),
+ );
+
+ case ShopInBitTicketDetail.routeName:
+ if (args is ShopInBitOrderModel) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => ShopInBitTicketDetail(model: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case GlobalSettingsView.routeName:
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
Why this scored 13/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.