feat: Implement unsaved changes guard in forms

- Added UnsavedChangesGuard widget to prevent accidental loss of unsaved changes when navigating away from forms.
- Integrated unsaved changes detection in TicketFormScreen and ProfileScreen.
- Updated TicketFormScreen to compare current form state against a baseline to track unsaved changes.
- Enhanced user experience by prompting for confirmation when attempting to leave a form with unsaved changes.
- Refactored ticket submission logic to ensure proper validation and error handling.
- Introduced TokenStorage service for secure storage of JWTs, migrating from SharedPreferences to FlutterSecureStorage.
- Updated AuthService to utilize TokenStorage for managing access and refresh tokens.
- Added unit tests for TokenStorage to ensure proper functionality and migration of tokens.
- Created tests for unsaved changes functionality to verify correct behavior in various scenarios.
This commit is contained in:
Ashwin 2026-08-07 08:45:36 +05:30
parent c1fa72b667
commit 8911551e2c
19 changed files with 1153 additions and 375 deletions

View file

@ -1,8 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Auto Backup is off deliberately. It defaults to on, which uploaded the
app's shared preferences to the user's Google Drive, and until 1.2.0
that file held both JWTs in the clear. It is also what
flutter_secure_storage asks for: a restored device has the ciphertext
but not the Keystore key, which raises "Failed to unwrap key".
Nothing of value is lost. Every record lives on the server, so a new
device needs a sign-in rather than a restore.
-->
<application
android:label="BottleCRM"
android:name="${applicationName}"
android:allowBackup="false"
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"

View file

@ -6,6 +6,7 @@ import '../../config/api_config.dart';
import '../../providers/auth_provider.dart';
import '../../routes/app_router.dart';
import '../../services/api_service.dart';
import '../../widgets/forms/unsaved_changes.dart';
/// Creating an organization from the phone.
///
@ -103,100 +104,110 @@ class _OrgCreateScreenState extends ConsumerState<OrgCreateScreen> {
context.go(AppRoutes.dashboard);
}
/// The timezone is a guess the screen made, not something the user typed,
/// so only a name counts as work worth keeping.
bool get _hasUnsavedChanges => _nameController.text.trim().isNotEmpty;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text('New Organization'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _nameController,
autofocus: true,
textCapitalization: TextCapitalization.words,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: 'Organization name',
hintText: 'e.g. Acme Inc.',
border: OutlineInputBorder(),
),
validator: (value) {
final name = value?.trim() ?? '';
if (name.isEmpty) return 'Give the organization a name';
// Mirrors the server's `validate_name`, which is the rule
// that actually decides. This only saves a round trip.
if (RegExp(r'''[~!@#$%^&*()+{}":;'/\[\]]''').hasMatch(name)) {
return 'Letters, numbers, spaces, hyphens and dots only';
}
return null;
},
),
const SizedBox(height: 20),
if (_loadingZones)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
)
else
DropdownButtonFormField<String>(
initialValue: _timezone,
isExpanded: true,
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _submitting,
child: Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text('New Organization'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _nameController,
autofocus: true,
textCapitalization: TextCapitalization.words,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: 'Time zone',
labelText: 'Organization name',
hintText: 'e.g. Acme Inc.',
border: OutlineInputBorder(),
),
items: _zones
.map(
(zone) => DropdownMenuItem(
value: zone.name,
child: Text(
zone.label,
overflow: TextOverflow.ellipsis,
validator: (value) {
final name = value?.trim() ?? '';
if (name.isEmpty) return 'Give the organization a name';
// Mirrors the server's `validate_name`, which is the rule
// that actually decides. This only saves a round trip.
if (RegExp(
r'''[~!@#$%^&*()+{}":;'/\[\]]''',
).hasMatch(name)) {
return 'Letters, numbers, spaces, hyphens and dots only';
}
return null;
},
),
const SizedBox(height: 20),
if (_loadingZones)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
)
else
DropdownButtonFormField<String>(
initialValue: _timezone,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Time zone',
border: OutlineInputBorder(),
),
items: _zones
.map(
(zone) => DropdownMenuItem(
value: zone.name,
child: Text(
zone.label,
overflow: TextOverflow.ellipsis,
),
),
),
)
.toList(),
onChanged: _submitting
? null
: (value) {
if (value != null) {
setState(() => _timezone = value);
}
},
),
const SizedBox(height: 6),
Text(
'Sets when a day starts here, so "due today" and "overdue" '
'mean what your team expects. You can change it later in '
'Settings.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create organization'),
.toList(),
onChanged: _submitting
? null
: (value) {
if (value != null) {
setState(() => _timezone = value);
}
},
),
const SizedBox(height: 6),
Text(
'Sets when a day starts here, so "due today" and "overdue" '
'mean what your team expects. You can change it later in '
'Settings.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create organization'),
),
),
],
),
),
),
),

View file

@ -9,6 +9,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/deals_provider.dart';
import '../../providers/lookup_provider.dart';
import '../../widgets/common/common.dart';
import '../../widgets/forms/unsaved_changes.dart';
import '../../widgets/forms/custom_fields_form.dart';
/// Deal Form Screen - Reusable for both Create and Edit
@ -282,35 +283,6 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
return true;
}
Future<bool> _onWillPop() async {
if (!_hasUnsavedChanges) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text(
'You have unsaved changes. Are you sure you want to leave?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Discard',
style: TextStyle(color: AppColors.danger600),
),
),
],
),
);
return result ?? false;
}
Deal _buildDeal() {
final amount = double.tryParse(_amountController.text.trim()) ?? 0.0;
final probability =
@ -533,18 +505,9 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
@override
Widget build(BuildContext context) {
return PopScope(
// While saving, swallow back navigation so the in-flight request doesn't
// get orphaned by the form unmounting.
canPop: !_hasUnsavedChanges && !_isLoading,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_isLoading) return;
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
},
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _isLoading,
child: Scaffold(
backgroundColor: AppColors.surface,
appBar: AppBar(
@ -556,16 +519,8 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
icon: const Icon(LucideIcons.chevronLeft),
onPressed: _isLoading
? null
: () async {
if (_hasUnsavedChanges) {
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
} else {
context.pop();
}
},
: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
),
),
body: _buildBody(),

View file

@ -10,6 +10,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/leads_provider.dart';
import '../../providers/lookup_provider.dart';
import '../../widgets/common/common.dart';
import '../../widgets/forms/unsaved_changes.dart';
// Salutation choices match the web form's hard-coded list and the backend's
// LEAD_SALUTATION column (free-text, but the UI restricts to these values).
@ -614,8 +615,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
? _formatAmount(lead.opportunityAmount!)
: '';
_currency = lead.currency ?? ref.read(selectedOrgProvider)?.defaultCurrency;
_probabilityController.text =
lead.probability != null ? lead.probability.toString() : '';
_probabilityController.text = lead.probability != null
? lead.probability.toString()
: '';
_closeDate = lead.closeDate;
_addressLineController.text = lead.addressLine ?? '';
_cityController.text = lead.city ?? '';
@ -676,8 +678,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
? _formatAmount(lead.opportunityAmount!)
: '') ||
(_currency ?? '') != (lead.currency ?? '') ||
_probabilityController.text !=
(lead.probability?.toString() ?? '') ||
_probabilityController.text != (lead.probability?.toString() ?? '') ||
_closeDate != lead.closeDate ||
_addressLineController.text != (lead.addressLine ?? '') ||
_cityController.text != (lead.city ?? '') ||
@ -721,35 +722,6 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
return true;
}
Future<bool> _onWillPop() async {
if (!_hasUnsavedChanges) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text(
'You have unsaved changes. Are you sure you want to leave?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Discard',
style: TextStyle(color: AppColors.danger600),
),
),
],
),
);
return result ?? false;
}
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{
'first_name': _firstNameController.text.trim(),
@ -801,8 +773,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
final phone = _phoneController.text.trim();
if (phone.isNotEmpty) {
payload['phone'] = phone;
} else if (widget.isEditMode &&
(_existingLead?.phone ?? '').isNotEmpty) {
} else if (widget.isEditMode && (_existingLead?.phone ?? '').isNotEmpty) {
// User cleared the phone in edit mode, null clears the column.
payload['phone'] = null;
}
@ -1016,18 +987,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
@override
Widget build(BuildContext context) {
return PopScope(
// While saving, swallow back navigation so the in-flight request
// doesn't get orphaned by the form unmounting.
canPop: !_hasUnsavedChanges && !_isLoading,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_isLoading) return;
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
},
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _isLoading,
child: Scaffold(
backgroundColor: AppColors.surface,
appBar: AppBar(
@ -1039,16 +1001,8 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
icon: const Icon(LucideIcons.chevronLeft),
onPressed: _isLoading
? null
: () async {
if (_hasUnsavedChanges) {
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
} else {
context.pop();
}
},
: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
),
),
body: _buildBody(),
@ -1347,9 +1301,8 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
maxLength: 50,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
validator: (v) => (v == null || v.trim().isEmpty)
? 'Last name is required'
: null,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Last name is required' : null,
),
],
);
@ -1393,9 +1346,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
keyboardType: TextInputType.phone,
maxLength: 20,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.allow(_phoneAllowed),
],
inputFormatters: [FilteringTextInputFormatter.allow(_phoneAllowed)],
onSubmitted: (_) => _normalizePhoneInPlace(),
validator: (value) {
final v = (value ?? '').trim();
@ -1520,9 +1471,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
_duplicateLeadId = null;
_duplicateLeadLabel = null;
});
final match = await ref
.read(leadsProvider.notifier)
.findLeadByEmail(email);
final match = await ref.read(leadsProvider.notifier).findLeadByEmail(email);
if (!mounted) return;
setState(() {
_checkingDuplicate = false;
@ -1718,9 +1667,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
onTap: _isLoading
? null
: () => _pickDate(
initial: _closeDate,
onSelect: (d) => setState(() => _closeDate = d),
),
initial: _closeDate,
onSelect: (d) => setState(() => _closeDate = d),
),
onClear: _closeDate == null || _isLoading
? null
: () => setState(() => _closeDate = null),
@ -1824,10 +1773,10 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
onTap: _isLoading
? null
: () => _pickDate(
initial: _lastContacted,
onSelect: (d) => setState(() => _lastContacted = d),
lastDate: DateTime.now(),
),
initial: _lastContacted,
onSelect: (d) => setState(() => _lastContacted = d),
lastDate: DateTime.now(),
),
onClear: _lastContacted == null || _isLoading
? null
: () => setState(() => _lastContacted = null),
@ -1841,9 +1790,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
onTap: _isLoading
? null
: () => _pickDate(
initial: _nextFollowUp,
onSelect: (d) => setState(() => _nextFollowUp = d),
),
initial: _nextFollowUp,
onSelect: (d) => setState(() => _nextFollowUp = d),
),
onClear: _nextFollowUp == null || _isLoading
? null
: () => setState(() => _nextFollowUp = null),
@ -1940,8 +1889,8 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
onChanged: (v) {
_customFieldValues[def.key] =
def.fieldType == CustomFieldType.number
? (v.trim().isEmpty ? null : v.trim())
: v;
? (v.trim().isEmpty ? null : v.trim())
: v;
},
);
case CustomFieldType.textarea:
@ -1968,11 +1917,11 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
onTap: _isLoading
? null
: () => _pickDate(
initial: dt,
onSelect: (d) => setState(() {
_customFieldValues[def.key] = _formatDate(d);
}),
),
initial: dt,
onSelect: (d) => setState(() {
_customFieldValues[def.key] = _formatDate(d);
}),
),
onClear: dt == null || _isLoading
? null
: () => setState(() => _customFieldValues[def.key] = null),
@ -1986,9 +1935,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
return _PickerField(
label: label,
value: selected.value.isEmpty ? 'Select' : selected.label,
onTap: _isLoading
? null
: () => _showCustomDropdownPicker(def),
onTap: _isLoading ? null : () => _showCustomDropdownPicker(def),
);
case CustomFieldType.checkbox:
final value = _customFieldValues[def.key] == true;
@ -2159,8 +2106,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
color: isSelected
? Colors.white
: AppColors.textSecondary,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w500,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
),
),
),
@ -2261,8 +2209,9 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
}
void _showSourcePicker() {
final sources =
LeadSource.values.where((s) => s != LeadSource.none).toList();
final sources = LeadSource.values
.where((s) => s != LeadSource.none)
.toList();
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,
@ -2496,10 +2445,7 @@ class _PickerField extends StatelessWidget {
),
child: Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Text(
value,
@ -2609,8 +2555,7 @@ class _SearchablePickerSheet extends StatefulWidget {
});
@override
State<_SearchablePickerSheet> createState() =>
_SearchablePickerSheetState();
State<_SearchablePickerSheet> createState() => _SearchablePickerSheetState();
}
class _SearchablePickerSheetState extends State<_SearchablePickerSheet> {
@ -2622,8 +2567,8 @@ class _SearchablePickerSheetState extends State<_SearchablePickerSheet> {
final filtered = q.isEmpty
? widget.options
: widget.options
.where((o) => o.label.toLowerCase().contains(q))
.toList();
.where((o) => o.label.toLowerCase().contains(q))
.toList();
return DraggableScrollableSheet(
initialChildSize: 0.7,

View file

@ -97,7 +97,7 @@ class _LeadsListScreenState extends ConsumerState<LeadsListScreen> {
children: [
_buildSearchBar(),
_buildFilterBar(),
_buildResultsCount(leads.length, data?.totalCount ?? 0),
_buildResultsCount(leadsAsync, leads.length, data?.totalCount ?? 0),
Expanded(child: _buildLeadsList(leadsAsync, leads)),
],
),
@ -306,11 +306,25 @@ class _LeadsListScreenState extends ConsumerState<LeadsListScreen> {
);
}
Widget _buildResultsCount(int loadedCount, int totalCount) {
Widget _buildResultsCount(
AsyncValue<LeadsListData> async,
int loadedCount,
int totalCount,
) {
// Until the first page lands there is no count to state. Printing
// "0 leads" over a spinner reads as an empty org, which is what made a
// failed load take a minute to tell apart from an empty list.
if (async.value == null) {
return _countStrip(async.hasError ? '' : 'Loading');
}
final isFiltered = _filters.isActive;
final label = isFiltered
? '$loadedCount of $totalCount lead${totalCount == 1 ? '' : 's'} (filtered)'
: '$totalCount lead${totalCount == 1 ? '' : 's'}';
return _countStrip(label);
}
Widget _countStrip(String label) {
return Container(
width: double.infinity,
color: AppColors.surfaceDim,

View file

@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/theme.dart';
import '../../data/models/profile.dart';
import '../../providers/profile_provider.dart';
import '../../widgets/common/common.dart';
import '../../widgets/forms/unsaved_changes.dart';
/// Profile view/edit screen.
///
@ -38,6 +38,17 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
setState(() => _editing = true);
}
/// Only counts while the fields are actually on screen. Outside edit mode
/// the controllers still hold whatever was last typed, and prompting about
/// that on the way out would be nonsense.
bool get _hasUnsavedChanges {
if (!_editing) return false;
final profile = ref.read(profileProvider).value;
if (profile == null) return false;
return _nameController.text.trim() != (profile.user.name ?? '') ||
_phoneController.text.trim() != (profile.phone ?? '');
}
void _cancelEdit() {
FocusScope.of(context).unfocus();
setState(() => _editing = false);
@ -78,34 +89,41 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
Widget build(BuildContext context) {
final async = ref.watch(profileProvider);
return Scaffold(
backgroundColor: AppColors.surfaceDim,
appBar: AppBar(
title: const Text('Profile'),
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: () => context.pop(),
),
actions: [
if (async.value != null && !_editing)
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: () => _enterEdit(async.value!),
icon: const Icon(LucideIcons.edit3, size: 20),
tooltip: 'Edit profile',
color: AppColors.primary600,
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _saving,
child: Scaffold(
backgroundColor: AppColors.surfaceDim,
appBar: AppBar(
title: const Text('Profile'),
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: _saving
? null
: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
),
actions: [
if (async.value != null && !_editing)
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: () => _enterEdit(async.value!),
icon: const Icon(LucideIcons.edit3, size: 20),
tooltip: 'Edit profile',
color: AppColors.primary600,
),
),
),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _buildError(e),
data: (profile) => _buildBody(profile),
],
),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _buildError(e),
data: (profile) => _buildBody(profile),
),
),
);
}
@ -251,7 +269,9 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
_InfoRow(
icon: LucideIcons.phone,
label: 'Phone',
value: profile.phone?.trim().isNotEmpty == true ? profile.phone! : '—',
value: profile.phone?.trim().isNotEmpty == true
? profile.phone!
: '—',
),
_sectionHeader('Organization'),
if (profile.org != null)

View file

@ -11,6 +11,7 @@ import '../../providers/lookup_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../widgets/common/common.dart';
import '../../widgets/forms/unsaved_changes.dart';
import '../../widgets/forms/custom_fields_form.dart';
import '../../widgets/forms/multi_select_sheet.dart';
@ -211,35 +212,6 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
return true;
}
Future<bool> _onWillPop() async {
if (!_hasUnsavedChanges) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text(
'You have unsaved changes. Are you sure you want to leave?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Discard',
style: TextStyle(color: AppColors.danger600),
),
),
],
),
);
return result ?? false;
}
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{
'title': _titleController.text.trim(),
@ -302,15 +274,9 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
},
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _isLoading,
child: Scaffold(
backgroundColor: AppColors.surface,
appBar: AppBar(
@ -320,16 +286,10 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: () async {
if (_hasUnsavedChanges) {
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
context.pop();
}
} else {
context.pop();
}
},
onPressed: _isLoading
? null
: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
),
),
body: _buildBody(),
@ -434,16 +394,8 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
// Cancel Button
Center(
child: GestureDetector(
onTap: () async {
if (_hasUnsavedChanges) {
final shouldPop = await _onWillPop();
if (shouldPop && mounted) {
context.pop();
}
} else {
context.pop();
}
},
onTap: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
child: Text(
'Cancel',
style: AppTypography.label.copyWith(

View file

@ -10,6 +10,7 @@ import '../../providers/lookup_provider.dart';
import '../../widgets/common/common.dart';
import '../../widgets/forms/custom_fields_form.dart';
import '../../widgets/forms/multi_select_sheet.dart';
import '../../widgets/forms/unsaved_changes.dart';
/// Shared create / edit form for tickets.
class TicketFormScreen extends ConsumerStatefulWidget {
@ -44,9 +45,25 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
@override
void initState() {
super.initState();
if (widget.isEditMode) _fetchTicket();
if (widget.isEditMode) {
_fetchTicket();
} else {
_baseline = _snapshot();
}
}
/// The form as it looked when it finished loading. Comparing against the
/// payload the form would submit means a field added later is covered
/// automatically, unlike a hand-listed comparison that has to be remembered.
String? _baseline;
/// `toString` rather than `jsonEncode`, which throws on the DateTime and
/// enum values these payloads can carry. Key order is stable because the
/// same builder produces every snapshot.
String _snapshot() => _buildPayload().toString();
bool get _hasUnsavedChanges => _baseline != null && _snapshot() != _baseline;
@override
void dispose() {
_nameController.dispose();
@ -80,6 +97,9 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
_fetchError = 'Failed to load ticket';
}
});
// After the fields hold the saved values, so an untouched edit form is
// clean rather than dirty against an empty baseline.
_baseline = _snapshot();
}
Map<String, dynamic> _buildPayload() {
@ -111,8 +131,7 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (!widget.isEditMode &&
(_accountId == null || _accountId!.isEmpty)) {
if (!widget.isEditMode && (_accountId == null || _accountId!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Please select an account.'),
@ -125,7 +144,9 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
if (_status == TicketStatus.closed && _closedOn == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Please set a closed-on date for closed tickets.'),
content: const Text(
'Please set a closed-on date for closed tickets.',
),
backgroundColor: AppColors.danger600,
behavior: SnackBarBehavior.floating,
),
@ -166,19 +187,26 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surface,
appBar: AppBar(
title: Text(widget.isEditMode ? 'Edit Ticket' : 'New Ticket'),
return UnsavedChangesGuard(
hasUnsavedChanges: () => _hasUnsavedChanges,
isSaving: _isLoading,
child: Scaffold(
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: () => context.pop(),
appBar: AppBar(
title: Text(widget.isEditMode ? 'Edit Ticket' : 'New Ticket'),
backgroundColor: AppColors.surface,
elevation: 0,
scrolledUnderElevation: 1,
leading: IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: _isLoading
? null
: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
),
),
body: _buildBody(),
),
body: _buildBody(),
);
}
@ -307,7 +335,8 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
const SizedBox(height: 16),
Center(
child: GestureDetector(
onTap: () => context.pop(),
onTap: () =>
leaveForm(context, hasUnsavedChanges: _hasUnsavedChanges),
child: Text(
'Cancel',
style: AppTypography.label.copyWith(
@ -327,9 +356,7 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
// Renders nothing if the org has no Case custom fields.
return Consumer(
builder: (context, ref, _) {
final asyncDefs = ref.watch(
customFieldDefinitionsProvider('Case'),
);
final asyncDefs = ref.watch(customFieldDefinitionsProvider('Case'));
final defs = asyncDefs.value ?? const [];
if (defs.isEmpty) return const SizedBox.shrink();
return Column(
@ -497,8 +524,7 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
spacing: 6,
runSpacing: 4,
children: [
for (final l in selectedLabels)
LabelPill(label: l),
for (final l in selectedLabels) LabelPill(label: l),
],
),
),

View file

@ -152,7 +152,11 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
_buildSearchBar(),
_buildQuickStatusChips(),
_buildFilterBar(),
_buildResultsCount(tickets.length, data?.totalCount ?? 0),
_buildResultsCount(
ticketsAsync,
tickets.length,
data?.totalCount ?? 0,
),
Expanded(child: _buildList(ticketsAsync, tickets)),
],
),
@ -487,17 +491,31 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
return 'Before ${fmt(before!)}';
}
Widget _buildResultsCount(int loadedCount, int totalCount) {
Widget _buildResultsCount(
AsyncValue<TicketsListData> async,
int loadedCount,
int totalCount,
) {
// Until the first page lands there is no count to state. Printing
// "0 tickets" over a spinner reads as an empty org, which is what made a
// failed load take a minute to tell apart from an empty list.
if (async.value == null) {
return _countStrip(async.hasError ? '' : 'Loading');
}
final unit = totalCount == 1 ? 'ticket' : 'tickets';
final text = totalCount > 0 && loadedCount < totalCount
? '$loadedCount of $totalCount $unit'
: '$totalCount $unit';
return _countStrip(_filters.hasAny ? '$text (filtered)' : text);
}
Widget _countStrip(String label) {
return Container(
width: double.infinity,
color: AppColors.surfaceDim,
padding: const EdgeInsets.fromLTRB(12, 6, 12, 8),
child: Text(
_filters.hasAny ? '$text (filtered)' : text,
label,
style: AppTypography.caption.copyWith(color: AppColors.textSecondary),
),
);

View file

@ -7,6 +7,7 @@ import '../config/api_config.dart';
import '../data/models/auth_response.dart';
import 'api_service.dart';
import 'crash_reporting.dart';
import 'token_storage.dart';
/// Authentication service for BottleCRM
///
@ -21,9 +22,12 @@ class AuthService {
final GoogleSignIn _googleSignIn = GoogleSignIn.instance;
final ApiService _apiService = ApiService();
/// The two JWTs are the only things that do not live in shared preferences.
/// See `token_storage.dart` for why, and for the migration that empties the
/// old location on upgrade.
final TokenStorage _tokenStorage = TokenStorage();
// Storage keys
static const String _accessTokenKey = 'jwt_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userKey = 'user_data';
static const String _organizationsKey = 'organizations';
static const String _selectedOrgKey = 'selected_organization';
@ -166,11 +170,10 @@ class AuthService {
Future<bool> requestMagicCode(String email) async {
try {
debugPrint('AuthService: Requesting magic code for $email...');
final response = await _apiService.post(
ApiConfig.magicLinkRequest,
{'email': email, 'delivery': 'code'},
requiresAuth: false,
);
final response = await _apiService.post(ApiConfig.magicLinkRequest, {
'email': email,
'delivery': 'code',
}, requiresAuth: false);
return response.success;
} catch (e) {
debugPrint('AuthService: requestMagicCode error: $e');
@ -189,11 +192,10 @@ class AuthService {
}) async {
try {
debugPrint('AuthService: Verifying magic code for $email...');
final response = await _apiService.post(
ApiConfig.magicLinkVerifyCode,
{'email': email, 'code': code},
requiresAuth: false,
);
final response = await _apiService.post(ApiConfig.magicLinkVerifyCode, {
'email': email,
'code': code,
}, requiresAuth: false);
if (!response.success || response.data == null) {
debugPrint(
@ -223,7 +225,8 @@ class AuthService {
// The nested read is kept as a fallback in case an older build of the
// API is on the other end.
final orgsList =
(data['organizations'] ?? userData?['organizations']) as List<dynamic>?;
(data['organizations'] ?? userData?['organizations'])
as List<dynamic>?;
_organizations = orgsList
?.map((org) => Organization.fromJson(org as Map<String, dynamic>))
.toList();
@ -483,8 +486,9 @@ class AuthService {
try {
final prefs = await SharedPreferences.getInstance();
_accessToken = prefs.getString(_accessTokenKey);
_refreshToken = prefs.getString(_refreshTokenKey);
final tokens = await _tokenStorage.read();
_accessToken = tokens.access;
_refreshToken = tokens.refresh;
final userJson = prefs.getString(_userKey);
if (userJson != null) {
@ -524,12 +528,8 @@ class AuthService {
try {
final prefs = await SharedPreferences.getInstance();
if (_accessToken != null) {
await prefs.setString(_accessTokenKey, _accessToken!);
}
if (_refreshToken != null) {
await prefs.setString(_refreshTokenKey, _refreshToken!);
}
await _tokenStorage.write(access: _accessToken, refresh: _refreshToken);
if (_currentUser != null) {
await prefs.setString(
_userKey,
@ -574,9 +574,9 @@ class AuthService {
/// Clear all stored authentication data
Future<void> _clearStorage() async {
try {
await _tokenStorage.clear();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
await prefs.remove(_userKey);
await prefs.remove(_organizationsKey);
await prefs.remove(_selectedOrgKey);

View file

@ -0,0 +1,121 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Where the JWTs live.
///
/// `mobile/CLAUDE.md` forbids putting tokens in `SharedPreferences` by name,
/// and until now that is exactly where both of them sat. On Android
/// `SharedPreferences` is a plain XML file in the app's data directory:
/// readable on a rooted device, and pullable through `adb backup` on any build
/// that has not turned backups off. A refresh token read out of it is good for
/// up to fourteen days, and no logout endpoint exists to cut it short.
///
/// Secure storage means the Android Keystore and the iOS Keychain, so the
/// ciphertext on disk is useless without the hardware-backed key.
///
/// Only the two tokens moved. The cached user, org list and selected org stay
/// in `SharedPreferences`: they are display data the UI already shows on
/// screen, and moving them would buy nothing while making every read slower.
class TokenStorage {
TokenStorage({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
static const String accessTokenKey = 'jwt_token';
static const String refreshTokenKey = 'refresh_token';
/// Reads both tokens, migrating them out of `SharedPreferences` first if a
/// previous build left them there.
Future<({String? access, String? refresh})> read() async {
await _migrateLegacyTokens();
return (
access: await _read(accessTokenKey),
refresh: await _read(refreshTokenKey),
);
}
/// Writes both tokens. A null clears its key rather than leaving the
/// previous value behind, so a token can never outlive the session that
/// obtained it.
Future<void> write({String? access, String? refresh}) async {
await _write(accessTokenKey, access);
await _write(refreshTokenKey, refresh);
}
/// Clears both tokens, including any legacy copy, so signing out on a
/// device that upgraded mid-session does not leave a usable refresh token
/// in the old location.
Future<void> clear() async {
await _write(accessTokenKey, null);
await _write(refreshTokenKey, null);
await _removeLegacyTokens();
}
/// Moves tokens written by a pre-secure-storage build, then deletes the
/// originals.
///
/// The delete is the point of the exercise. Copying alone would leave the
/// readable plaintext on disk forever on every device that ever ran an
/// older build, so the upgrade would fix new installs and no one else.
///
/// Migration is skipped once secure storage holds an access token, so the
/// common path is one read rather than three.
Future<void> _migrateLegacyTokens() async {
if (await _read(accessTokenKey) != null) return;
final SharedPreferences prefs;
try {
prefs = await SharedPreferences.getInstance();
} catch (e) {
debugPrint('TokenStorage: legacy check skipped: $e');
return;
}
final legacyAccess = prefs.getString(accessTokenKey);
final legacyRefresh = prefs.getString(refreshTokenKey);
if (legacyAccess == null && legacyRefresh == null) return;
await _write(accessTokenKey, legacyAccess);
await _write(refreshTokenKey, legacyRefresh);
await prefs.remove(accessTokenKey);
await prefs.remove(refreshTokenKey);
debugPrint('TokenStorage: migrated tokens out of shared preferences');
}
Future<void> _removeLegacyTokens() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(accessTokenKey);
await prefs.remove(refreshTokenKey);
} catch (e) {
debugPrint('TokenStorage: legacy clear skipped: $e');
}
}
/// Secure storage talks to the Keystore and can fail: a corrupt entry after
/// an OS restore, or a locked device on iOS. A throw here would take out
/// app startup, so a failed read is treated as "no token", which lands the
/// user on the sign-in screen instead of a crash.
Future<String?> _read(String key) async {
try {
return await _storage.read(key: key);
} catch (e) {
debugPrint('TokenStorage: read failed for $key: $e');
return null;
}
}
Future<void> _write(String key, String? value) async {
try {
if (value == null) {
await _storage.delete(key: key);
} else {
await _storage.write(key: key, value: value);
}
} catch (e) {
debugPrint('TokenStorage: write failed for $key: $e');
}
}
}

View file

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/theme.dart';
/// Leaving a form that has been filled in.
///
/// THE TRAP THIS EXISTS TO CLOSE
/// Three screens already carried a discard prompt and it fired on none of
/// them. They wrote `canPop: !_hasUnsavedChanges`, which reads the flag once,
/// while `build` runs. Typing into a `TextFormField` rebuilds that field and
/// not its parent, so on a form that started empty `canPop` stays `true` for
/// the rest of its life and the system back gesture pops before
/// `onPopInvoked` is ever consulted. The dirty check was correct and simply
/// never ran.
///
/// So `canPop` is always `false` here and the decision happens in the
/// callback, where the state being read is the state as it is now. A form
/// cannot silently lose its guard by gaining a field that nobody remembered
/// to rebuild on.
///
/// The cost is the iOS interactive swipe-back and the Android predictive-back
/// preview on these five screens. Worth it: a form is exactly where an
/// accidental edge swipe is expensive.
///
/// Everything routes through `context.pop()`, which is a direct pop and so
/// bypasses `PopScope` entirely. That is what lets the AppBar chevron, the
/// Cancel link and a successful save all leave without re-asking.
class UnsavedChangesGuard extends StatelessWidget {
const UnsavedChangesGuard({
super.key,
required this.hasUnsavedChanges,
required this.child,
this.isSaving = false,
});
/// Read when back is pressed, not when this widget builds. Pass a closure
/// over the form's live state, never a captured bool.
final bool Function() hasUnsavedChanges;
/// While a save is in flight, back does nothing. Popping here would orphan
/// the request and leave the user unsure whether the record was created.
final bool isSaving;
final Widget child;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop || isSaving) return;
await leaveForm(context, hasUnsavedChanges: hasUnsavedChanges());
},
child: child,
);
}
}
/// Leaves the form, asking first if there is anything to lose.
///
/// Used by the AppBar chevron and the Cancel link as well as by the guard, so
/// all three routes out of a form behave the same. Before this, a screen could
/// prompt on the back gesture and discard silently on its own Cancel button.
Future<void> leaveForm(
BuildContext context, {
required bool hasUnsavedChanges,
}) async {
if (!hasUnsavedChanges) {
context.pop();
return;
}
final discard = await confirmDiscard(context);
if (discard && context.mounted) context.pop();
}
/// The prompt itself. Defaults to keeping the work: dismissing the dialog by
/// tapping outside returns null, which is read as "do not discard".
Future<bool> confirmDiscard(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text(
'You have unsaved changes. Are you sure you want to leave?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Discard', style: TextStyle(color: AppColors.danger600)),
),
],
),
);
return result ?? false;
}

View file

@ -275,6 +275,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter

View file

@ -16,6 +16,9 @@ dependencies:
http: ^1.1.0
google_sign_in: ^7.1.1
shared_preferences: ^2.5.5
# Tokens live here, never in shared_preferences. Keystore-backed on Android,
# Keychain on iOS. See lib/services/token_storage.dart.
flutter_secure_storage: ^10.3.1
jwt_decoder: ^2.0.1
package_info_plus: ^10.1.0

View file

@ -3,6 +3,7 @@ import 'package:bottle_crm/providers/auth_provider.dart';
import 'package:bottle_crm/screens/auth/org_create_screen.dart';
import 'package:bottle_crm/screens/auth/org_selection_screen.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@ -17,7 +18,9 @@ void main() {
testWidgets('a user with no organizations is given the action', (
tester,
) async {
await tester.pumpWidget(_host(const OrgSelectionScreen(), orgs: const []));
await tester.pumpWidget(
_host(const OrgSelectionScreen(), orgs: const []),
);
await tester.pump();
expect(find.text('Create an organization'), findsOneWidget);
@ -66,6 +69,32 @@ void main() {
);
});
testWidgets('back after typing a name asks first', (tester) async {
final router = GoRouter(
initialLocation: '/picker',
routes: [
GoRoute(path: '/picker', builder: (_, _) => const Text('the picker')),
GoRoute(path: '/new', builder: (_, _) => const OrgCreateScreen()),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [authProvider.overrideWith(() => _FakeAuth(const []))],
child: MaterialApp.router(routerConfig: router),
),
);
await tester.pumpAndSettle();
router.push('/new');
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), 'Acme Inc.');
tester.state<NavigatorState>(find.byType(Navigator).last).maybePop();
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsOneWidget);
expect(find.text('the picker'), findsNothing);
});
testWidgets('accepts an ordinary name', (tester) async {
await tester.pumpWidget(_host(const OrgCreateScreen()));
await tester.pump();
@ -85,10 +114,11 @@ void main() {
});
}
Widget _host(Widget screen, {List<Organization> orgs = const []}) => ProviderScope(
overrides: [authProvider.overrideWith(() => _FakeAuth(orgs))],
child: MaterialApp(home: screen),
);
Widget _host(Widget screen, {List<Organization> orgs = const []}) =>
ProviderScope(
overrides: [authProvider.overrideWith(() => _FakeAuth(orgs))],
child: MaterialApp(home: screen),
);
class _FakeAuth extends AuthNotifier {
_FakeAuth(this.orgs);

View file

@ -0,0 +1,71 @@
import 'dart:async';
import 'package:bottle_crm/providers/leads_provider.dart';
import 'package:bottle_crm/screens/leads/leads_list_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
/// The header stated a number before it had one. For the whole of a slow load,
/// and for the whole of a failed one, the screen read "0 leads" over a spinner,
/// which is indistinguishable from an org with no leads. It took a minute to
/// tell an offline phone from an empty list, and I misread it myself.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<void> pump(
WidgetTester tester,
LeadsNotifier Function() notifier,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [leadsProvider.overrideWith(notifier)],
child: const MaterialApp(home: LeadsListScreen()),
),
);
await tester.pump();
}
testWidgets('while the first page is in flight it does not claim zero', (
tester,
) async {
await pump(tester, _StillLoading.new);
expect(find.text('0 leads'), findsNothing);
expect(find.text('Loading'), findsOneWidget);
});
testWidgets('once the page lands it states the real count', (tester) async {
await pump(tester, () => _Loaded(7));
await tester.pump();
// The control. A header that always said "Loading" would pass the test
// above and tell nobody anything.
expect(find.text('7 leads'), findsOneWidget);
expect(find.text('Loading'), findsNothing);
});
testWidgets('an org that really has none still says so', (tester) async {
await pump(tester, () => _Loaded(0));
await tester.pump();
// "0 leads" is the right answer here, and the fix must not have made it
// unreachable.
expect(find.text('0 leads'), findsOneWidget);
});
}
class _StillLoading extends LeadsNotifier {
@override
Future<LeadsListData> build() => Completer<LeadsListData>().future;
}
class _Loaded extends LeadsNotifier {
_Loaded(this.total);
final int total;
@override
Future<LeadsListData> build() async =>
LeadsListData(totalCount: total, hasMore: false);
}

View file

@ -0,0 +1,86 @@
import 'package:bottle_crm/data/models/auth_response.dart';
import 'package:bottle_crm/data/models/profile.dart';
import 'package:bottle_crm/providers/profile_provider.dart';
import 'package:bottle_crm/screens/settings/profile_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
/// The profile screen is an edit form too, and it had the same hole as the
/// other five: back threw the edits away without asking. Its rule differs from
/// theirs, because the fields only exist while edit mode is on.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<void> openProfile(WidgetTester tester) async {
final router = GoRouter(
initialLocation: '/more',
routes: [
GoRoute(path: '/more', builder: (_, _) => const Text('the more sheet')),
GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [profileProvider.overrideWith(() => _FakeProfile())],
child: MaterialApp.router(routerConfig: router),
),
);
await tester.pumpAndSettle();
router.push('/profile');
await tester.pumpAndSettle();
}
Future<void> pressBack(WidgetTester tester) async {
tester.state<NavigatorState>(find.byType(Navigator).last).maybePop();
await tester.pumpAndSettle();
}
testWidgets('back after changing the name asks first', (tester) async {
await openProfile(tester);
await tester.tap(find.byTooltip('Edit profile'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Ada King');
await pressBack(tester);
expect(find.text('Discard changes?'), findsOneWidget);
expect(find.text('the more sheet'), findsNothing);
});
testWidgets('back while just reading leaves straight away', (tester) async {
await openProfile(tester);
await pressBack(tester);
// The control. Most visits to this screen only look at it, and prompting
// those would train people to dismiss the dialog without reading it.
expect(find.text('Discard changes?'), findsNothing);
expect(find.text('the more sheet'), findsOneWidget);
});
testWidgets('opening edit and changing nothing is not a change', (
tester,
) async {
await openProfile(tester);
await tester.tap(find.byTooltip('Edit profile'));
await tester.pumpAndSettle();
await pressBack(tester);
// The fields are pre-filled from the saved profile, so being in edit mode
// is not by itself something to lose.
expect(find.text('Discard changes?'), findsNothing);
expect(find.text('the more sheet'), findsOneWidget);
});
}
class _FakeProfile extends ProfileNotifier {
@override
Future<Profile> build() async => const Profile(
id: 'p1',
user: AuthUser(id: 'u1', email: 'user@example.com', name: 'Ada Lovelace'),
phone: '+1 555 0100',
);
}

View file

@ -0,0 +1,234 @@
import 'package:bottle_crm/services/token_storage.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// `mobile/CLAUDE.md` forbids keeping tokens in `SharedPreferences` by name,
/// and both JWTs sat there until now. These tests pin the two halves of the
/// fix: new tokens go to secure storage, and tokens an older build already
/// wrote are moved out of the readable file rather than merely shadowed.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// The map handed to `setMockInitialValues` is the live backing store, so
/// holding a reference is how a test sees what really landed in the
/// keychain.
late Map<String, String> secure;
void useStores({
Map<String, String> secureSeed = const {},
Map<String, Object> prefsSeed = const {},
}) {
secure = Map<String, String>.from(secureSeed);
FlutterSecureStorage.setMockInitialValues(secure);
SharedPreferences.setMockInitialValues(prefsSeed);
}
group('where a token ends up', () {
test(
'a written token is in secure storage and not in preferences',
() async {
useStores();
final storage = TokenStorage();
await storage.write(access: 'access-jwt', refresh: 'refresh-jwt');
expect(secure[TokenStorage.accessTokenKey], 'access-jwt');
expect(secure[TokenStorage.refreshTokenKey], 'refresh-jwt');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(TokenStorage.accessTokenKey), isNull);
expect(prefs.getString(TokenStorage.refreshTokenKey), isNull);
},
);
test('what was written is what comes back', () async {
useStores();
final storage = TokenStorage();
await storage.write(access: 'access-jwt', refresh: 'refresh-jwt');
final tokens = await storage.read();
expect(tokens.access, 'access-jwt');
expect(tokens.refresh, 'refresh-jwt');
});
test('writing null clears rather than leaving the old value', () async {
useStores();
final storage = TokenStorage();
await storage.write(access: 'access-jwt', refresh: 'refresh-jwt');
// The session ended without a full clear. A token that survived here
// would outlive the session that obtained it.
await storage.write(access: null, refresh: null);
expect(secure, isEmpty);
});
});
group('upgrading from a build that used preferences', () {
test('legacy tokens are moved into secure storage', () async {
useStores(
prefsSeed: {
TokenStorage.accessTokenKey: 'old-access',
TokenStorage.refreshTokenKey: 'old-refresh',
},
);
final tokens = await TokenStorage().read();
// The user stays signed in across the upgrade.
expect(tokens.access, 'old-access');
expect(tokens.refresh, 'old-refresh');
expect(secure[TokenStorage.accessTokenKey], 'old-access');
});
test('and are deleted from preferences, which is the point', () async {
useStores(
prefsSeed: {
TokenStorage.accessTokenKey: 'old-access',
TokenStorage.refreshTokenKey: 'old-refresh',
},
);
await TokenStorage().read();
// Copying alone would leave readable plaintext on disk forever on every
// device that ever ran an older build, so the upgrade would protect new
// installs and nobody else.
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(TokenStorage.accessTokenKey), isNull);
expect(prefs.getString(TokenStorage.refreshTokenKey), isNull);
});
test('a legacy value never overwrites a newer secure one', () async {
useStores(
secureSeed: {
TokenStorage.accessTokenKey: 'current-access',
TokenStorage.refreshTokenKey: 'current-refresh',
},
prefsSeed: {TokenStorage.accessTokenKey: 'stale-access'},
);
final tokens = await TokenStorage().read();
// Migration is skipped once secure storage holds an access token.
// Without that guard a stale copy would sign the user back into an
// older session on the next launch.
expect(tokens.access, 'current-access');
});
test('other preferences are left alone', () async {
useStores(
prefsSeed: {
TokenStorage.accessTokenKey: 'old-access',
'user_data': '{"id":"u1"}',
'selected_organization': '{"id":"o1"}',
},
);
await TokenStorage().read();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('user_data'), '{"id":"u1"}');
expect(prefs.getString('selected_organization'), '{"id":"o1"}');
});
});
group('signing out', () {
test('clear empties secure storage', () async {
useStores(
secureSeed: {
TokenStorage.accessTokenKey: 'access-jwt',
TokenStorage.refreshTokenKey: 'refresh-jwt',
},
);
await TokenStorage().clear();
expect(secure, isEmpty);
});
test('clear also empties a legacy copy left by an older build', () async {
// Signing out on a device that upgraded mid-session, before any read
// had a chance to migrate. Missing this leaves a refresh token good for
// fourteen days behind a UI that says you are signed out.
useStores(
prefsSeed: {
TokenStorage.accessTokenKey: 'old-access',
TokenStorage.refreshTokenKey: 'old-refresh',
},
);
await TokenStorage().clear();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(TokenStorage.accessTokenKey), isNull);
expect(prefs.getString(TokenStorage.refreshTokenKey), isNull);
});
});
group('when the keystore itself fails', () {
test(
'a read failure signs the user out rather than crashing launch',
() async {
SharedPreferences.setMockInitialValues({});
final storage = TokenStorage(storage: _BrokenSecureStorage());
final tokens = await storage.read();
expect(tokens.access, isNull);
expect(tokens.refresh, isNull);
},
);
});
}
/// A corrupt keychain entry after an OS restore, or a locked device on iOS.
/// Real, and a throw out of `read` would take out app startup.
class _BrokenSecureStorage implements FlutterSecureStorage {
@override
Future<String?> read({
required String key,
dynamic iOptions,
dynamic aOptions,
dynamic lOptions,
dynamic webOptions,
dynamic mOptions,
dynamic wOptions,
}) async {
throw PlatformExceptionStub();
}
@override
Future<void> write({
required String key,
required String? value,
dynamic iOptions,
dynamic aOptions,
dynamic lOptions,
dynamic webOptions,
dynamic mOptions,
dynamic wOptions,
}) async {
throw PlatformExceptionStub();
}
@override
Future<void> delete({
required String key,
dynamic iOptions,
dynamic aOptions,
dynamic lOptions,
dynamic webOptions,
dynamic mOptions,
dynamic wOptions,
}) async {
throw PlatformExceptionStub();
}
@override
noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class PlatformExceptionStub implements Exception {}

View file

@ -0,0 +1,133 @@
import 'package:bottle_crm/widgets/forms/unsaved_changes.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
/// Three form screens already carried a discard prompt and it fired on none of
/// them, because `canPop` was computed during `build` and typing into a field
/// never rebuilds its parent. The prompt was correct and simply unreachable.
///
/// So the test that matters is the one that types and then presses back
/// WITHOUT any rebuild in between. Against the old code it walks straight out
/// of the form.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// Opens the form the way the app does: pushed on top of a list, so there is
/// somewhere to go back to. The form never calls setState when its field
/// changes, which is what every real form here does.
Future<void> openForm(
WidgetTester tester, {
required TextEditingController controller,
bool isSaving = false,
}) async {
final router = GoRouter(
initialLocation: '/list',
routes: [
GoRoute(path: '/list', builder: (_, _) => const Text('the list')),
GoRoute(
path: '/form',
builder: (_, _) => UnsavedChangesGuard(
hasUnsavedChanges: () => controller.text.isNotEmpty,
isSaving: isSaving,
child: Scaffold(body: TextField(controller: controller)),
),
),
],
);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();
router.push('/form');
await tester.pumpAndSettle();
}
/// What the OS back gesture does. `maybePop` is the call that consults
/// PopScope, which a direct `pop` bypasses.
Future<void> pressBack(WidgetTester tester) async {
final navigator = tester.state<NavigatorState>(find.byType(Navigator).last);
navigator.maybePop();
await tester.pumpAndSettle();
}
testWidgets('back after typing asks before throwing the work away', (
tester,
) async {
final controller = TextEditingController();
await openForm(tester, controller: controller);
await tester.enterText(
find.byType(TextField),
'Important Deal I do not Want to lose',
);
await pressBack(tester);
expect(find.text('Discard changes?'), findsOneWidget);
expect(find.text('the list'), findsNothing);
});
testWidgets('back on an untouched form leaves without a prompt', (
tester,
) async {
final controller = TextEditingController();
await openForm(tester, controller: controller);
await pressBack(tester);
// The control. A guard that always prompted would pass the test above.
expect(find.text('Discard changes?'), findsNothing);
expect(find.text('the list'), findsOneWidget);
});
testWidgets('Cancel keeps the form and the typing', (tester) async {
final controller = TextEditingController();
await openForm(tester, controller: controller);
await tester.enterText(find.byType(TextField), 'half-written note');
await pressBack(tester);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('the list'), findsNothing);
expect(controller.text, 'half-written note');
});
testWidgets('Discard leaves', (tester) async {
final controller = TextEditingController();
await openForm(tester, controller: controller);
await tester.enterText(find.byType(TextField), 'half-written note');
await pressBack(tester);
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(find.text('the list'), findsOneWidget);
});
testWidgets('dismissing the prompt keeps the form, it does not leave', (
tester,
) async {
final controller = TextEditingController();
await openForm(tester, controller: controller);
await tester.enterText(find.byType(TextField), 'half-written note');
await pressBack(tester);
// Tapping the scrim returns null, which must read as "keep", never as
// "discard".
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(find.text('the list'), findsNothing);
});
testWidgets('back does nothing while a save is in flight', (tester) async {
final controller = TextEditingController(text: 'already typed');
await openForm(tester, controller: controller, isSaving: true);
await pressBack(tester);
// Popping here would orphan the request and leave the user unsure whether
// the record was created.
expect(find.text('Discard changes?'), findsNothing);
expect(find.text('the list'), findsNothing);
});
}