Temporary setup of alternative to advertisements
This commit is contained in:
parent
ad2d932842
commit
57a9793a40
14 changed files with 653 additions and 56 deletions
156
lib/widgets/ad_placeholder_screen.dart
Normal file
156
lib/widgets/ad_placeholder_screen.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_navigator.dart';
|
||||
|
||||
/// How long a non-gating placeholder stays up before closing itself —
|
||||
/// short enough not to be a nuisance while testing, long enough to be
|
||||
/// clearly noticed rather than flickering past.
|
||||
const _autoDismissAfter = Duration(seconds: 4);
|
||||
|
||||
/// Stands in for a real ad while `adsEnabled` is false — see
|
||||
/// lib/services/ad_config.dart for why that exists.
|
||||
///
|
||||
/// [mustConfirm] mirrors the difference between this app's two kinds of ad:
|
||||
/// - `false` (the report-export and fuel-save interstitials): closes
|
||||
/// itself after [_autoDismissAfter], the same "you didn't have to do
|
||||
/// anything" feel as a plain interstitial.
|
||||
/// - `true` (the cloud-sync gate): waits for the user to tap Continue,
|
||||
/// standing in for a rewarded ad's watch-it-through requirement, since
|
||||
/// the upload genuinely shouldn't proceed until they've acted.
|
||||
///
|
||||
/// Returns once the placeholder has been dismissed, so callers can treat it
|
||||
/// exactly like awaiting a real ad's show-and-dismiss cycle. A no-op (and
|
||||
/// an immediate return) if the navigator isn't mounted yet.
|
||||
Future<void> showAdPlaceholder({required bool mustConfirm}) async {
|
||||
final navigator = navigatorKey.currentState;
|
||||
if (navigator == null) return;
|
||||
|
||||
await navigator.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: true,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (_, _, _) => AdPlaceholderScreen(mustConfirm: mustConfirm),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class AdPlaceholderScreen extends StatefulWidget {
|
||||
/// See [showAdPlaceholder] — true for the sync gate, false otherwise.
|
||||
final bool mustConfirm;
|
||||
|
||||
const AdPlaceholderScreen({super.key, required this.mustConfirm});
|
||||
|
||||
@override
|
||||
State<AdPlaceholderScreen> createState() => _AdPlaceholderScreenState();
|
||||
}
|
||||
|
||||
class _AdPlaceholderScreenState extends State<AdPlaceholderScreen> {
|
||||
Timer? _ticker;
|
||||
late int _secondsLeft = _autoDismissAfter.inSeconds;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.mustConfirm) return;
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() => _secondsLeft--);
|
||||
if (_secondsLeft <= 0) _dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _dismiss() {
|
||||
_ticker?.cancel();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
return PopScope(
|
||||
// A real ad can't be dismissed with the system back gesture either;
|
||||
// letting it through here would let the sync gate be bypassed.
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
'ADVERTISEMENT',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
letterSpacing: 1.6,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icon/hero_icon.png',
|
||||
height: 96,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'An advertisement will appear here',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Ads are not running yet. This placeholder stands in '
|
||||
'for one so the rest of the app behaves exactly as it '
|
||||
'will once they are.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.mustConfirm)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _dismiss,
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Closing in $_secondsLeft…',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,34 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
|||
|
||||
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
|
||||
final picker = ImagePicker();
|
||||
|
||||
// Raised *before* the camera/gallery opens rather than after it returns.
|
||||
// [ImagePicker.pickImage] doesn't complete when the camera closes — it
|
||||
// also downscales the captured photo (several MB straight off the
|
||||
// sensor), which takes seconds on a busy device. With the progress
|
||||
// shown only afterward, that whole stretch left the user staring at an
|
||||
// unchanged Receipts list with no sign anything was happening, which
|
||||
// reads as "the capture did nothing" — and tapping away during it
|
||||
// genuinely does abandon the capture.
|
||||
var progressShowing = false;
|
||||
void showProgress() {
|
||||
if (progressShowing || !context.mounted) return;
|
||||
progressShowing = true;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _ReceiptProgressDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
void hideProgress() {
|
||||
if (!progressShowing || !context.mounted) return;
|
||||
progressShowing = false;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
showProgress();
|
||||
|
||||
XFile? photo;
|
||||
try {
|
||||
photo = await picker.pickImage(
|
||||
|
|
@ -37,6 +65,7 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
|||
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||
);
|
||||
} catch (e) {
|
||||
hideProgress();
|
||||
if (context.mounted) {
|
||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -45,16 +74,16 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (photo == null) return;
|
||||
// Cancelled (backed out of the camera, or didn't confirm the shot) —
|
||||
// nothing to say about that, it was deliberate.
|
||||
if (photo == null) {
|
||||
hideProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
final imageFile = File(photo.path);
|
||||
|
||||
if (!context.mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
final ocrService = OcrService();
|
||||
String recognizedText = '';
|
||||
|
|
@ -69,7 +98,7 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
|||
final parsed = ReceiptParser.parse(recognizedText);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // close the OCR loading spinner
|
||||
hideProgress();
|
||||
|
||||
final state = parsed.state;
|
||||
if (state != null && state != 'MO') {
|
||||
|
|
@ -90,6 +119,45 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
|||
}
|
||||
}
|
||||
|
||||
/// Shown from the moment the camera/gallery is opened until the parsed
|
||||
/// receipt is ready to review. Deliberately says what's happening rather
|
||||
/// than showing a bare spinner: the wait here is mostly downscaling and
|
||||
/// reading the photo, which is long enough on a slow device that an
|
||||
/// unexplained spinner invites tapping away mid-capture.
|
||||
class _ReceiptProgressDialog extends StatelessWidget {
|
||||
const _ReceiptProgressDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
// Backing out here wouldn't cancel the underlying capture, it would
|
||||
// just hide it and strand the user.
|
||||
canPop: false,
|
||||
child: Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
Text('Reading your receipt…', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'This can take a few seconds.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
|
||||
/// warn if the receipt's address is somewhere else, and let the user
|
||||
/// decide whether to log it anyway (e.g. it might still be worth tracking
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue