show request shipping tracking links if available
What changed, and why it matters
This commit adds a feature that displays shipping tracking links on a purchase ticket detail page. When a user taps a link, the app tries to open it in an external browser. The change is mostly a normal UI feature, but it does accept tracking-link strings from ticket data and pass them directly to the phone's URL opener. That creates a small risk if a malicious or compromised server sends a harmful link, because the app will try to open whatever it receives without visible filtering.
Treat this as a low-priority hardening item. Add allow-list validation for tracking-link URLs (e.g., scheme limited to https/http, host matching known carriers), sanitize input before calling Uri.parse, and consider showing the URL to the user with a confirmation dialog before invoking launchUrl. Review `splitTrackingLinks` to ensure it cannot return arbitrary strings.
Security signals we found
External URL launch from server-supplied data without visible validation
Use of Uri.parse on untrusted strings
Potential open-in-browser behavior driven by backend field 'trackingLink'
No user confirmation shown before launching link
Evidence from the diff
The patch introduces a _TrackingLinks widget in shopinbit_ticket_detail.dart that reads ticket?.trackingLink, splits it via splitTrackingLinks, and renders each resulting string as a CustomTextButton. The button’s onTap calls launchUrl(Uri.parse(e), mode: LaunchMode.externalApplication). Supporting changes add an overflow property to CustomTextButton and alignment parameters to DetailItemBase. There is no visible validation of the tracking-link values before parsing or launching, and no user confirmation step. The source of ticket is server-provided ShopInBit data.
Changed components
lib/pages/shopinbit/shopinbit_ticket_detail.dartlib/widgets/custom_buttons/blue_text_button.dartlib/widgets/detail_item.dartInspect captured patch +90 / −19
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 8126fe1..577b0cd 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -24,8 +24,10 @@ import '../../utilities/util.dart';
import '../../widgets/background.dart';
import '../../widgets/conditional_parent.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/custom_buttons/blue_text_button.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
+import '../../widgets/detail_item.dart';
import '../../widgets/dialogs/s_dialog.dart';
import '../../widgets/loading_indicator.dart';
import '../../widgets/refresh_control.dart';
@@ -220,6 +222,8 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
final isCarResearch = ticket?.category == ShopInBitCategory.car;
final messages = <TicketMessage>[...?ticket?.messages, ..._pending];
+ final trackingLinks = splitTrackingLinks(ticket?.trackingLink).toList();
+
final statusBar = Padding(
padding: .only(bottom: isDesktop ? 12 : 8),
child: RoundedWhiteContainer(
@@ -452,6 +456,11 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
statusBar,
offerBanner,
requestDetailsSection,
+ if (trackingLinks.isNotEmpty)
+ Padding(
+ padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8),
+ child: _TrackingLinks(trackingLinks: trackingLinks),
+ ),
chatArea,
SizedBox(height: isDesktop ? 12 : 8),
inputBar,
@@ -927,3 +936,53 @@ class _AttachmentImageFallback extends StatelessWidget {
);
}
}
+
+class _TrackingLinks extends StatelessWidget {
+ const _TrackingLinks({super.key, required this.trackingLinks});
+
+ final List<String> trackingLinks;
+
+ @override
+ Widget build(BuildContext context) {
+ return DetailItemBase(
+ horizontal: true,
+ expandDetail: true,
+ crossAxisAlignment: .start,
+ title: Text(
+ "Tracking link(s)",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ detail: Column(
+ mainAxisSize: .min,
+ crossAxisAlignment: .start,
+ children: [
+ ...trackingLinks.map(
+ (e) => CustomTextButton(
+ text: e,
+ overflow: .ellipsis,
+ onTap: () async {
+ try {
+ await launchUrl(
+ Uri.parse(e),
+ mode: LaunchMode.externalApplication,
+ );
+ } catch (e, s) {
+ Logging.instance.e(
+ "Failed to open shipping tracking link",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ borderColor: Util.isDesktop
+ ? Theme.of(context).extension<StackColors>()!.textFieldDefaultBG
+ : null,
+ );
+ }
+}
diff --git a/lib/widgets/custom_buttons/blue_text_button.dart b/lib/widgets/custom_buttons/blue_text_button.dart
index eee64d8..8304498 100644
--- a/lib/widgets/custom_buttons/blue_text_button.dart
+++ b/lib/widgets/custom_buttons/blue_text_button.dart
@@ -10,6 +10,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+
import '../../themes/stack_colors.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
@@ -25,6 +26,7 @@ class _CustomTextButton extends StatefulWidget {
this.onTap,
this.enabled = true,
this.textSize,
+ required this.overflow,
});
final String text;
@@ -33,6 +35,7 @@ class _CustomTextButton extends StatefulWidget {
final double? textSize;
final Color enabledColor;
final Color disabledColor;
+ final TextOverflow overflow;
@override
State<_CustomTextButton> createState() => _CustomTextButtonState();
@@ -103,22 +106,22 @@ class _CustomTextButtonState extends State<_CustomTextButton>
},
child: RichText(
textAlign: TextAlign.center,
+ overflow: widget.overflow,
text: TextSpan(
text: widget.text,
style: widget.textSize == null
- ? STextStyles.link2(context).copyWith(
- color: color,
- )
- : STextStyles.link2(context).copyWith(
- color: color,
- fontSize: widget.textSize,
- ),
+ ? STextStyles.link2(context).copyWith(color: color)
+ : STextStyles.link2(
+ context,
+ ).copyWith(color: color, fontSize: widget.textSize),
recognizer: widget.enabled
? (TapGestureRecognizer()
- ..onTap = () {
- widget.onTap?.call();
- controller?.forward().then((value) => controller?.reverse());
- })
+ ..onTap = () {
+ widget.onTap?.call();
+ controller?.forward().then(
+ (value) => controller?.reverse(),
+ );
+ })
: null,
),
),
@@ -133,26 +136,29 @@ class CustomTextButton extends StatelessWidget {
this.onTap,
this.enabled = true,
this.textSize,
+ this.overflow = .clip,
});
final String text;
final VoidCallback? onTap;
final bool enabled;
final double? textSize;
+ final TextOverflow overflow;
@override
Widget build(BuildContext context) {
return _CustomTextButton(
key: UniqueKey(),
text: text,
- enabledColor: Theme.of(context)
- .extension<StackColors>()!
- .customTextButtonEnabledText,
- disabledColor: Theme.of(context)
- .extension<StackColors>()!
- .customTextButtonDisabledText,
+ enabledColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.customTextButtonEnabledText,
+ disabledColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.customTextButtonDisabledText,
enabled: enabled,
textSize: textSize,
+ overflow: overflow,
onTap: onTap,
);
}
diff --git a/lib/widgets/detail_item.dart b/lib/widgets/detail_item.dart
index b45a36d..af6c62c 100644
--- a/lib/widgets/detail_item.dart
+++ b/lib/widgets/detail_item.dart
@@ -82,6 +82,8 @@ class DetailItemBase extends StatelessWidget {
this.borderColor,
this.expandDetail = false,
this.noPadding = false,
+ this.crossAxisAlignment,
+ this.mainAxisAlignment,
});
final Widget title;
@@ -91,6 +93,8 @@ class DetailItemBase extends StatelessWidget {
final Color? borderColor;
final bool expandDetail;
final bool noPadding;
+ final CrossAxisAlignment? crossAxisAlignment;
+ final MainAxisAlignment? mainAxisAlignment;
@override
Widget build(BuildContext context) {
@@ -113,7 +117,8 @@ class DetailItemBase extends StatelessWidget {
),
child: horizontal
? Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ mainAxisAlignment: mainAxisAlignment ?? .spaceBetween,
+ crossAxisAlignment: crossAxisAlignment ?? .center,
children: [
title,
if (expandDetail) const SizedBox(width: 16),
@@ -125,7 +130,8 @@ class DetailItemBase extends StatelessWidget {
],
)
: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: mainAxisAlignment ?? .start,
+ crossAxisAlignment: crossAxisAlignment ?? .start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Why this scored 21/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.