diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dafc1e..4fc28b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ What changed in each version of the app, newest first. The format is [Semantic Versioning](https://semver.org/), and the file is kept by [cider](https://pub.dev/packages/cider): see "Versions" in docs/runbook.md. +## [4.1.0] - 2026-10-16 +### Added +- Search expenses and recorded payments from the group toolbar, across currencies, using descriptions and notes saved on this device. + +### Changed +- Groups use connected rows with clearer, softer Material surfaces and more visible artwork in light mode. Neutral cards share the same surface treatment, and expense sections use outlined cards. + +### Fixed +- Expense and payment editors ask before discarding changes. The whole export settings row opens the format picker, which remains scrollable with large text. +- Clearing Insights search or filters also clears the visible search text, and restored searches display their saved query. + ## [4.0.0] - 2026-10-15 ### Changed - When a numbered API version is retired, saved data stays readable and pending uploads pause. Update OpenSplit through Play or restart into the new web release to edit again. @@ -106,6 +117,7 @@ What changed in each version of the app, newest first. The format is ### Added - The first build, to Play internal testing. +[4.1.0]: https://github.com/eigeninteractive/opensplit/compare/v4.0.0...v4.1.0 [4.0.0]: https://github.com/eigeninteractive/opensplit/compare/v3.1.0...v4.0.0 [3.1.0]: https://github.com/eigeninteractive/opensplit/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/eigeninteractive/opensplit/compare/v2.0.0...v3.0.0 diff --git a/lib/application/ledger_providers.dart b/lib/application/ledger_providers.dart index 9179bfc..67d7dcd 100644 --- a/lib/application/ledger_providers.dart +++ b/lib/application/ledger_providers.dart @@ -107,6 +107,11 @@ Stream> members(Ref ref, String groupId) => Stream> entries(Ref ref, String groupId) => ref.watch(entryRepositoryProvider).watchEntries(groupId); +/// Local group search, independent of the currency and filters in Insights. +@riverpod +Stream> groupSearchResults(Ref ref, String groupId, String query) => + ref.watch(entryRepositoryProvider).search(groupId, query); + /// One historical entry, including records removed from current balances. @riverpod Stream entryRecord(Ref ref, String entryId) => diff --git a/lib/data/local/entry_search.dart b/lib/data/local/entry_search.dart new file mode 100644 index 0000000..f601875 --- /dev/null +++ b/lib/data/local/entry_search.dart @@ -0,0 +1,11 @@ +/// Quotes user terms for SQLite FTS5, matching each term as a word prefix. +/// +/// Shared by group search and Insights so both interpret typed text the same +/// way. The returned expression must still be passed as a bound SQL variable. +String? entrySearchMatch(String query) { + final terms = query + .replaceAll('"', ' ') + .split(RegExp(r'\s+')) + .where((term) => term.isNotEmpty); + return terms.isEmpty ? null : terms.map((term) => '"$term"*').join(' '); +} diff --git a/lib/data/repositories/drift_analytics_repository.dart b/lib/data/repositories/drift_analytics_repository.dart index ab3f373..30cb798 100644 --- a/lib/data/repositories/drift_analytics_repository.dart +++ b/lib/data/repositories/drift_analytics_repository.dart @@ -4,6 +4,7 @@ import '../../domain/calendar_date.dart'; import '../../domain/models/entry.dart'; import '../../domain/analytics/analytics_query.dart'; import '../local/database.dart'; +import '../local/entry_search.dart'; import 'drift_entry_repository.dart'; /// Local analytics. @@ -50,7 +51,7 @@ final class DriftAnalyticsRepository { vars.add(Variable(filter.memberId!)); } - final match = _ftsMatch(filter.query); + final match = entrySearchMatch(filter.query); if (match != null) { clauses.add( '$alias.rowid IN ' @@ -62,17 +63,6 @@ final class DriftAnalyticsRepository { return (sql: clauses.join(' AND '), vars: vars); } - /// Turns user input into an FTS5 query. - static String? _ftsMatch(String query) { - final terms = query - .replaceAll('"', ' ') - .split(RegExp(r'\s+')) - .where((t) => t.isNotEmpty) - .toList(); - if (terms.isEmpty) return null; - return terms.map((t) => '"$t"*').join(' '); - } - Stream> search(AnalyticsFilter filter) { final where = _where(filter); return _db diff --git a/lib/data/repositories/drift_entry_repository.dart b/lib/data/repositories/drift_entry_repository.dart index 3b92522..4965bd4 100644 --- a/lib/data/repositories/drift_entry_repository.dart +++ b/lib/data/repositories/drift_entry_repository.dart @@ -8,6 +8,7 @@ import '../../domain/models/entry.dart'; import '../../domain/models/entry_snapshot.dart'; import '../../domain/split/splitter.dart'; import '../local/database.dart'; +import '../local/entry_search.dart'; import '../local/entry_writer.dart'; import '../local/tables.dart'; import '../sync/outbox_queue.dart'; @@ -98,6 +99,30 @@ final class DriftEntryRepository { return [for (final id in ids) ?byId[id]]; } + /// Matches descriptions and notes across a group's currencies and entry kinds. + /// + /// Empty input yields no results. Deleted entries are excluded, and local + /// edits or incoming sync changes update the stream automatically. + Stream> search(String groupId, String query) { + final match = entrySearchMatch(query); + if (match == null) return Stream.value(const []); + return _db + .customSelect( + 'SELECT e.id FROM entries e ' + 'WHERE e.group_id = ? AND e.deleted_at IS NULL ' + 'AND e.rowid IN ' + '(SELECT rowid FROM entries_fts WHERE entries_fts MATCH ?) ' + 'ORDER BY e.entry_date DESC, e.occurred_at DESC NULLS LAST, ' + 'e.created_at DESC', + variables: [Variable(groupId), Variable(match)], + readsFrom: {_db.entries, _db.entryPayers, _db.entryShares}, + ) + .watch() + .asyncMap( + (rows) => getByIds([for (final row in rows) row.read('id')]), + ); + } + /// Every entry in a group, most recent first. Stream> watchEntries( String groupId, { diff --git a/lib/presentation/screens/account_screen.dart b/lib/presentation/screens/account_screen.dart index ccbfd74..f807a04 100644 --- a/lib/presentation/screens/account_screen.dart +++ b/lib/presentation/screens/account_screen.dart @@ -318,6 +318,7 @@ class _Identity extends ConsumerWidget { final named = name?.trim() ?? ''; return Card.filled( + color: scheme.surfaceContainerHigh, margin: EdgeInsets.zero, child: InkWell( borderRadius: BorderRadius.circular(12), diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index 2bb3595..f1f21d5 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -14,6 +14,7 @@ import '../feedback.dart'; import '../navigation.dart'; import '../theme.dart'; import '../widgets/amount_card.dart'; +import '../widgets/form_leave_guard.dart'; import '../widgets/entry_history.dart'; import '../widgets/avatar_view.dart'; import '../widgets/category_icon.dart'; @@ -154,185 +155,193 @@ class _Editor extends ConsumerWidget { final textTheme = Theme.of(context).textTheme; final ready = ledger.canEdit && !form.saving && currency != null; - return Scaffold( - appBar: AppBar( - leading: CloseButton(onPressed: () => goBack(context, _groupPath)), - title: Text( - !ledger.canEdit - ? 'Expense' - : form.isEditing - ? 'Edit expense' - : 'Add expense', - ), - actions: [ - if (form.isEditing) - IconButton( - tooltip: 'Delete', - onPressed: ready ? () => _delete(context) : null, - icon: const Icon(Icons.delete_outline), - ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: FilledButton( - onPressed: ready ? () => _save(context) : null, - child: const Text('Save'), - ), + return FormLeaveGuard( + draft: form.copyWith(saving: false, error: null), + saving: form.saving, + fallback: _groupPath, + builder: (context, leave) => Scaffold( + appBar: AppBar( + leading: CloseButton(onPressed: form.saving ? null : leave), + title: Text( + !ledger.canEdit + ? 'Expense' + : form.isEditing + ? 'Edit expense' + : 'Add expense', ), - ], - ), - body: ExcludeFocus( - excluding: form.saving, - child: AbsorbPointer( - absorbing: form.saving, - child: PageBody( - maxWidth: 640, - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 48), - children: [ - ExcludeFocus( - excluding: !ledger.canEdit, - child: AbsorbPointer( - absorbing: !ledger.canEdit, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AmountCard( - heroTag: form.editing == null - ? null - : ('entry-amount', form.editing!.id), - currency: currency, - currencyCode: form.currencyCode, - amount: form.amount, - autofocus: !form.isEditing, - onAmountChanged: controller.setAmount, - onCurrencyChanged: controller.chooseCurrency, - ), - if (currency != null && - currency.code != ledger.group.defaultCurrency) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '${currency.code} balances stay separate.', - style: textTheme.bodySmall, + actions: [ + if (form.isEditing) + IconButton( + tooltip: 'Delete', + onPressed: ready ? () => _delete(context) : null, + icon: const Icon(Icons.delete_outline), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton( + onPressed: ready ? () => _save(context) : null, + child: const Text('Save'), + ), + ), + ], + ), + body: ExcludeFocus( + excluding: form.saving, + child: AbsorbPointer( + absorbing: form.saving, + child: PageBody( + maxWidth: 640, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 48), + children: [ + ExcludeFocus( + excluding: !ledger.canEdit, + child: AbsorbPointer( + absorbing: !ledger.canEdit, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AmountCard( + heroTag: form.editing == null + ? null + : ('entry-amount', form.editing!.id), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + autofocus: !form.isEditing, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, + ), + if (currency != null && + currency.code != ledger.group.defaultCurrency) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${currency.code} balances stay separate.', + style: textTheme.bodySmall, + ), + ), + const SizedBox(height: 16), + SharedText( + tag: ( + 'entry-description', + form.editing?.id ?? ('draft', ledger.group.id), + ), + text: form.description, + style: textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + child: TextFormField( + initialValue: form.description, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'What was it?', + hintText: 'Dinner at Toit', + ), + onChanged: controller.setDescription, ), ), - const SizedBox(height: 16), - SharedText( - tag: ( - 'entry-description', - form.editing?.id ?? ('draft', ledger.group.id), + const SizedBox(height: 16), + // The details most expenses leave as they are, as chips rather + // than a field each. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CategoryChip( + heroTag: form.editing == null + ? null + : ('entry-category', form.editing!.id), + value: form.categoryId, + onChanged: controller.chooseCategory, + ), + ActionChip( + avatar: const Icon(Icons.event_outlined), + label: Text(_dayName(form.day)), + tooltip: 'Change the day', + onPressed: () => _pickDay(context), + ), + ], ), - text: form.description, - style: textTheme.bodyLarge!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + const SizedBox(height: 24), + _EditorCard( + child: _PayerSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, + ), ), - child: TextFormField( - initialValue: form.description, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'What was it?', - hintText: 'Dinner at Toit', + const SizedBox(height: 16), + _EditorCard( + child: _SplitSection( + ledger: ledger, + currency: currency, + form: form, + controller: controller, ), - onChanged: controller.setDescription, ), - ), - const SizedBox(height: 16), - // The details most expenses leave as they are, as chips rather - // than a field each. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _CategoryChip( - heroTag: form.editing == null - ? null - : ('entry-category', form.editing!.id), - value: form.categoryId, - onChanged: controller.chooseCategory, + const SizedBox(height: 16), + ExpansionTile( + title: const Text('Time of expense'), + subtitle: Text( + form.occurredAt == null + ? 'Date only' + : DateFormat.jm().format( + form.occurredAt!.toLocal(), + ), ), - ActionChip( - avatar: const Icon(Icons.event_outlined), - label: Text(_dayName(form.day)), - tooltip: 'Change the day', - onPressed: () => _pickDay(context), + leading: const Icon(Icons.schedule_outlined), + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, ), - ], - ), - const SizedBox(height: 24), - _EditorCard( - child: _PayerSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, - ), - ), - const SizedBox(height: 16), - _EditorCard( - child: _SplitSection( - ledger: ledger, - currency: currency, - form: form, - controller: controller, + childrenPadding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16, + ), + expandedCrossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _TimeChip( + shown: form.occurredAt?.toLocal(), + onPick: device == null + ? null + : () => _pickTime(context, device), + onClear: controller.clearTime, + ), + ], ), - ), - const SizedBox(height: 16), - ExpansionTile( - title: const Text('Time of expense'), - subtitle: Text( - form.occurredAt == null - ? 'Date only' - : DateFormat.jm().format( - form.occurredAt!.toLocal(), + if (form.error case final error?) ...[ + const SizedBox(height: 16), + Card.filled( + color: Theme.of( + context, + ).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + error, + style: textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, ), - ), - leading: const Icon(Icons.schedule_outlined), - tilePadding: const EdgeInsets.symmetric( - horizontal: 16, - ), - childrenPadding: const EdgeInsets.fromLTRB( - 16, - 0, - 16, - 16, - ), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TimeChip( - shown: form.occurredAt?.toLocal(), - onPick: device == null - ? null - : () => _pickTime(context, device), - onClear: controller.clearTime, - ), - ], - ), - if (form.error case final error?) ...[ - const SizedBox(height: 16), - Card.filled( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - error, - style: textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, ), ), ), - ), + ], ], - ], + ), ), ), - ), - if (form.editing case final editing?) ...[ - const SizedBox(height: 32), - EntryHistory(entryId: editing.id, ledger: ledger), + if (form.editing case final editing?) ...[ + const SizedBox(height: 32), + EntryHistory(entryId: editing.id, ledger: ledger), + ], ], - ], + ), ), ), ), @@ -359,8 +368,7 @@ class _EditorCard extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) => Card.filled( - color: Theme.of(context).colorScheme.surfaceContainerLow, + Widget build(BuildContext context) => Card.outlined( child: Padding(padding: const EdgeInsets.all(16), child: child), ); } diff --git a/lib/presentation/screens/group_detail_screen.dart b/lib/presentation/screens/group_detail_screen.dart index 1e4c931..5305b60 100644 --- a/lib/presentation/screens/group_detail_screen.dart +++ b/lib/presentation/screens/group_detail_screen.dart @@ -8,6 +8,7 @@ import 'package:material_ui/material_ui.dart'; import 'package:opensplit_api/opensplit_api.dart' show EntryKind; import '../../application/ledger_providers.dart'; +import '../../application/backend_providers.dart'; import '../../application/sync_providers.dart'; import '../../data/local/database.dart'; import '../../domain/calendar_date.dart'; @@ -24,6 +25,7 @@ import '../widgets/group_cover.dart'; import '../widgets/shared_element.dart'; import '../widgets/group_pane.dart'; import '../widgets/group_standing.dart'; +import '../widgets/group_search_button.dart'; import '../widgets/offline_indicator.dart'; import '../widgets/page_body.dart'; import '../widgets/group_access.dart'; @@ -130,7 +132,7 @@ class GroupDetailScreen extends ConsumerWidget { /// The group's own top app bar: its name, its cover and picture, and where /// you stand, collapsing to the name and the tabs once you scroll. -class _GroupAppBar extends StatelessWidget { +class _GroupAppBar extends ConsumerWidget { const _GroupAppBar({ required this.ledger, required this.tabs, @@ -145,9 +147,18 @@ class _GroupAppBar extends StatelessWidget { final double measure; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final groupId = ledger.group.id; - void open(String path) => context.push('/g/$groupId/$path'); + final compactRefresh = kIsWeb && MediaQuery.sizeOf(context).width < 400; + final sync = ref.watch(syncControllerProvider); + final canRefresh = sync.enabled && !ref.watch(requiresAppUpdateProvider); + void open(String path) { + if (path == 'refresh') { + ref.read(syncControllerProvider.notifier).syncGroup(groupId); + } else { + context.push('/g/$groupId/$path'); + } + } return SliverAppBar( pinned: true, @@ -158,7 +169,8 @@ class _GroupAppBar extends StatelessWidget { title: _CollapsedGroupName(name: ledger.group.name), actions: [ const OfflineIndicator(), - if (kIsWeb) SyncRefreshButton.group(groupId), + if (kIsWeb && !compactRefresh) SyncRefreshButton.group(groupId), + GroupSearchButton(key: ValueKey(groupId), groupId: groupId), if (ledger.canEdit) IconButton( tooltip: 'Settle up', @@ -174,6 +186,16 @@ class _GroupAppBar extends StatelessWidget { tooltip: 'More', onSelected: open, itemBuilder: (context) => [ + if (compactRefresh && canRefresh) + PopupMenuItem( + value: 'refresh', + enabled: !sync.isSyncing, + child: ListTile( + leading: const Icon(Icons.refresh), + title: Text(sync.isSyncing ? 'Syncing…' : 'Refresh'), + contentPadding: EdgeInsets.zero, + ), + ), for (final action in _moreActions) PopupMenuItem( value: action.path, diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index 636de63..d68676c 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -12,6 +12,7 @@ import '../launch_hold.dart'; import '../widgets/group_avatar_hero.dart'; import '../widgets/group_cover.dart'; import '../widgets/shared_element.dart'; +import '../widgets/segmented_list.dart'; import '../widgets/brand_mark.dart'; import '../widgets/conflicting_edit_banner.dart'; import '../widgets/create_group_sheet.dart'; @@ -111,11 +112,13 @@ abstract final class _GroupList { // be folding balances. sliver: SliverList.builder( itemCount: groups.length, - itemBuilder: (context, index) => Padding( - padding: EdgeInsets.only( - bottom: index == groups.length - 1 ? 0 : 12, + itemBuilder: (context, index) => Segment( + index: index, + count: groups.length, + child: _GroupTile( + group: groups[index], + corners: Segment.radiusAt(index: index, count: groups.length), ), - child: _GroupTile(group: groups[index]), ), ), ), @@ -123,84 +126,79 @@ abstract final class _GroupList { ]; } -/// The way to the groups that are no longer in this list. +/// A group's identity and balances, opening its expenses and people. class _GroupTile extends ConsumerWidget { - const _GroupTile({required this.group}); + const _GroupTile({required this.group, required this.corners}); final Group group; + final BorderRadius corners; @override Widget build(BuildContext context, WidgetRef ref) { final ledger = ref.watch(groupLedgerProvider(group.id)); final currencies = ref.watch(currenciesProvider).value ?? const {}; final theme = Theme.of(context); - const corners = BorderRadius.all(Radius.circular(20)); - - // Stock cards and tiles retain Material's ink, focus and touch behaviour. - return Card.filled( - color: theme.colorScheme.surfaceContainerLow, - shape: const RoundedRectangleBorder(borderRadius: corners), - child: Stack( - children: [ - if (ledger != null) - Positioned.fill( - child: GroupCoverHero( - ledger: ledger, - borderRadius: corners, - patternFade: 1, - ), + // The segment owns containment; the tile keeps Material's ink and focus. + return Stack( + children: [ + if (ledger != null) + Positioned.fill( + child: GroupCoverHero( + ledger: ledger, + borderRadius: corners, + patternFade: 1, ), - Material( - type: MaterialType.transparency, - child: ListTile( - onTap: () => context.push('/g/${group.id}'), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - minLeadingWidth: 56, - titleAlignment: ListTileTitleAlignment.top, - leading: GroupAvatarHero(group: group, radius: 28), - title: SharedText( - tag: ('group-name', group.id), - text: group.name, - maxLines: null, - style: theme.textTheme.titleLarge!.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), + ), + Material( + type: MaterialType.transparency, + child: ListTile( + onTap: () => context.push('/g/${group.id}'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + minLeadingWidth: 56, + titleAlignment: ListTileTitleAlignment.top, + leading: GroupAvatarHero(group: group, radius: 28), + title: SharedText( + tag: ('group-name', group.id), + text: group.name, + maxLines: null, + style: theme.textTheme.titleLarge!.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, ), - subtitle: ledger == null - ? const LaunchPlaceholder(child: SizedBox(height: 20)) - : Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (ledger.me != null) ...[ - Text( - '${ledger.members.length} ' - '${ledger.members.length == 1 ? 'member' : 'members'}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + ), + subtitle: ledger == null + ? const LaunchPlaceholder(child: SizedBox(height: 20)) + : Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (ledger.me != null) ...[ + Text( + '${ledger.members.length} ' + '${ledger.members.length == 1 ? 'member' : 'members'}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 4), - ], - GroupStanding( - ledger: ledger, - currencies: currencies, - style: theme.textTheme.bodyLarge, - overflow: TextOverflow.clip, ), + const SizedBox(height: 4), ], - ), + GroupStanding( + ledger: ledger, + currencies: currencies, + style: theme.textTheme.bodyLarge, + overflow: TextOverflow.clip, + ), + ], ), - ), + ), ), - ], - ), + ), + ], ); } } diff --git a/lib/presentation/screens/group_settings_screen.dart b/lib/presentation/screens/group_settings_screen.dart index fbd9023..a854cad 100644 --- a/lib/presentation/screens/group_settings_screen.dart +++ b/lib/presentation/screens/group_settings_screen.dart @@ -258,17 +258,7 @@ class _GroupSettingsScreenState extends ConsumerState { ), const SizedBox(height: 24), SegmentedList( - children: [ - ListTile( - leading: const Icon(Icons.ios_share), - title: const Text('Export this group'), - subtitle: const Text( - 'Download a spreadsheet or full backup.', - ), - isThreeLine: true, - trailing: ExportButton(groupId: widget.groupId), - ), - ], + children: [ExportButton.tile(groupId: widget.groupId)], ), const SizedBox(height: 24), diff --git a/lib/presentation/screens/insights_screen.dart b/lib/presentation/screens/insights_screen.dart index 1edb92c..e0dbfc6 100644 --- a/lib/presentation/screens/insights_screen.dart +++ b/lib/presentation/screens/insights_screen.dart @@ -58,25 +58,9 @@ class InsightsScreen extends ConsumerWidget { slivers: [ SliverList.list( children: [ - // SearchBar is Material 3's search field: a - // pill-shaped bar on surfaceContainerHigh with its - // own elevation and leading/trailing slots. - SearchBar( - hintText: 'Search descriptions and notes', - leading: const Icon(Icons.search), - trailing: [ - if (filter.query.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - tooltip: 'Clear search', - onPressed: () => controller.update( - filter.copyWith(query: ''), - ), - ), - ], - onChanged: (value) => controller.update( - filter.copyWith(query: value), - ), + _InsightsSearchBar( + key: ValueKey(groupId), + groupId: groupId, ), const SizedBox(height: 12), if (used.length > 1) @@ -153,6 +137,70 @@ class InsightsScreen extends ConsumerWidget { } } +class _InsightsSearchBar extends ConsumerStatefulWidget { + const _InsightsSearchBar({super.key, required this.groupId}); + + final String groupId; + + @override + ConsumerState<_InsightsSearchBar> createState() => _InsightsSearchBarState(); +} + +class _InsightsSearchBarState extends ConsumerState<_InsightsSearchBar> { + late final TextEditingController _text; + + @override + void initState() { + super.initState(); + final provider = analyticsFilterControllerProvider(widget.groupId); + _text = TextEditingController(text: ref.read(provider).query); + ref.listenManual(provider.select((filter) => filter.query), (_, query) { + // Preserve selection and IME composition when the update came from typing. + if (_text.text == query) return; + _text.value = TextEditingValue( + text: query, + selection: TextSelection.collapsed(offset: query.length), + ); + }); + } + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _updateQuery(String query) { + final provider = analyticsFilterControllerProvider(widget.groupId); + final filter = ref.read(provider); + if (filter.query == query) return; + ref.read(provider.notifier).update(filter.copyWith(query: query)); + } + + @override + Widget build(BuildContext context) { + final query = ref.watch( + analyticsFilterControllerProvider( + widget.groupId, + ).select((filter) => filter.query), + ); + return SearchBar( + controller: _text, + hintText: 'Search descriptions and notes', + leading: const Icon(Icons.search), + trailing: [ + if (query.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear search', + onPressed: () => _updateQuery(''), + ), + ], + onChanged: _updateQuery, + ); + } +} + class _MemberFilter extends ConsumerWidget { const _MemberFilter({ required this.groupId, @@ -230,6 +278,7 @@ class _Section extends StatelessWidget { ], const SizedBox(height: 8), Card.filled( + color: Theme.of(context).colorScheme.surfaceContainerHigh, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 134052b..f676436 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -110,7 +110,9 @@ class _AccountRow extends ConsumerWidget { return Card.filled( // Coloured only while something needs doing. A permanent warning tint on // a settled account would be crying wolf on every visit. - color: anonymous ? scheme.secondaryContainer : null, + color: anonymous + ? scheme.secondaryContainer + : scheme.surfaceContainerHigh, child: ListTile( onTap: () => context.go('/account'), leading: Icon( diff --git a/lib/presentation/screens/settle_up_screen.dart b/lib/presentation/screens/settle_up_screen.dart index a671bd8..f5a5dde 100644 --- a/lib/presentation/screens/settle_up_screen.dart +++ b/lib/presentation/screens/settle_up_screen.dart @@ -18,6 +18,7 @@ import '../../domain/settle/upi.dart'; import '../navigation.dart'; import '../feedback.dart'; import '../widgets/amount_card.dart'; +import '../widgets/form_leave_guard.dart'; import '../widgets/entry_history.dart'; import '../widgets/page_body.dart'; import '../widgets/shared_element.dart'; @@ -170,180 +171,184 @@ class _SettleUp extends ConsumerWidget { id: ?ledger.memberById(id), }.values.toList(); - return Scaffold( - appBar: AppBar( - leading: BackButton( - onPressed: () => goBack(context, '/g/${ledger.group.id}'), - ), - title: Text( - !ledger.canEdit - ? 'Payment' - : form.isEditing - ? 'Edit payment' - : 'Settle up', - ), - actions: [ - if (form.isEditing) ...[ - IconButton( - tooltip: 'Delete payment', - onPressed: ready ? () => _delete(context) : null, - icon: const Icon(Icons.delete_outline), - ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: FilledButton( - onPressed: ready ? () => _record(context, ref) : null, - child: const Text('Save'), + return FormLeaveGuard( + draft: form.copyWith(saving: false, error: null), + saving: form.saving, + fallback: '/g/${ledger.group.id}', + builder: (context, leave) => Scaffold( + appBar: AppBar( + leading: BackButton(onPressed: form.saving ? null : leave), + title: Text( + !ledger.canEdit + ? 'Payment' + : form.isEditing + ? 'Edit payment' + : 'Settle up', + ), + actions: [ + if (form.isEditing) ...[ + IconButton( + tooltip: 'Delete payment', + onPressed: ready ? () => _delete(context) : null, + icon: const Icon(Icons.delete_outline), ), - ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton( + onPressed: ready ? () => _record(context, ref) : null, + child: const Text('Save'), + ), + ), + ], ], - ], - ), - body: PageBody( - maxWidth: 640, - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), - children: [ - ExcludeFocus( - excluding: !ledger.canEdit, - child: AbsorbPointer( - absorbing: !ledger.canEdit, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (editing != null) ...[ - Row( - children: [ - SharedElement( - tag: ('entry-category', editing.id), - child: Icon( - Icons.handshake_outlined, - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, + ), + body: PageBody( + maxWidth: 640, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + ExcludeFocus( + excluding: !ledger.canEdit || form.saving, + child: AbsorbPointer( + absorbing: !ledger.canEdit || form.saving, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (editing != null) ...[ + Row( + children: [ + SharedElement( + tag: ('entry-category', editing.id), + child: Icon( + Icons.handshake_outlined, + color: Theme.of( + context, + ).colorScheme.onTertiaryContainer, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: SharedText( - tag: ('entry-description', editing.id), - text: - '${ledger.nameOf(from ?? '')} paid ' - '${ledger.nameOf(to ?? '')}', - style: Theme.of(context).textTheme.titleLarge! - .copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - maxLines: 2, + const SizedBox(width: 12), + Expanded( + child: SharedText( + tag: ('entry-description', editing.id), + text: + '${ledger.nameOf(from ?? '')} paid ' + '${ledger.nameOf(to ?? '')}', + style: Theme.of(context).textTheme.titleLarge! + .copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + maxLines: 2, + ), ), - ), - ], + ], + ), + const SizedBox(height: 24), + ], + AmountCard( + heroTag: editing != null + ? ('entry-amount', editing.id) + : transferIdentity == null + ? null + : ('settlement-amount', transferIdentity!), + currency: currency, + currencyCode: form.currencyCode, + amount: form.amount, + label: 'Amount', + error: form.error, + onAmountChanged: controller.setAmount, + onCurrencyChanged: controller.chooseCurrency, ), const SizedBox(height: 24), - ], - AmountCard( - heroTag: editing != null - ? ('entry-amount', editing.id) - : transferIdentity == null - ? null - : ('settlement-amount', transferIdentity!), - currency: currency, - currencyCode: form.currencyCode, - amount: form.amount, - label: 'Amount', - error: form.error, - onAmountChanged: controller.setAmount, - onCurrencyChanged: controller.chooseCurrency, - ), - const SizedBox(height: 24), - _MemberDropdown( - heroTag: transferIdentity == null - ? null - : ('settlement-from', transferIdentity!), - label: form.isEditing ? 'Paid by' : 'Who is paying', - members: members, - nameOf: ledger.nameOfMember, - meId: ledger.me?.id, - value: from, - onChanged: controller.choosePayer, - ), - const SizedBox(height: 16), - _MemberDropdown( - heroTag: transferIdentity == null - ? null - : ('settlement-to', transferIdentity!), - label: form.isEditing ? 'Paid to' : 'Who is being paid', - members: members, - nameOf: ledger.nameOfMember, - meId: ledger.me?.id, - value: to, - excludeId: from, - onChanged: controller.choosePayee, - ), - const SizedBox(height: 16), - if (!form.isEditing && - from != null && - to != null && - currency != null) ...[ - const SizedBox(height: 8), - _OutstandingHint( - ledger: ledger, - from: from, - to: to, - currency: currency, + _MemberDropdown( + heroTag: transferIdentity == null + ? null + : ('settlement-from', transferIdentity!), + label: form.isEditing ? 'Paid by' : 'Who is paying', + members: members, + nameOf: ledger.nameOfMember, + meId: ledger.me?.id, + value: from, + onChanged: controller.choosePayer, ), - ], - const SizedBox(height: 8), - _PaymentDetails(form: form, controller: controller), - const SizedBox(height: 24), - if (!form.isEditing && - currency != null && - currency.code == 'INR') ...[ - _UpiSection( - payeeName: payeeName, - // A new payee, or their handle arriving with their profile, - // is a new field: what it shows is never left over from - // somebody else. - fieldKey: ValueKey((to, form.knownHandleIn(ledger))), - handle: form.handleIn(ledger), - onHandleChanged: to == null + const SizedBox(height: 16), + _MemberDropdown( + heroTag: transferIdentity == null ? null - : controller.setHandle, - uri: buildUpiPaymentUri( - payeeVpa: form.handleIn(ledger), - payeeName: payeeName, - amountMinor: currency.parseToMinor(form.amount) ?? 0, + : ('settlement-to', transferIdentity!), + label: form.isEditing ? 'Paid to' : 'Who is being paid', + members: members, + nameOf: ledger.nameOfMember, + meId: ledger.me?.id, + value: to, + excludeId: from, + onChanged: controller.choosePayee, + ), + const SizedBox(height: 16), + if (!form.isEditing && + from != null && + to != null && + currency != null) ...[ + const SizedBox(height: 8), + _OutstandingHint( + ledger: ledger, + from: from, + to: to, currency: currency, ), - onPaid: () => _record(context, ref), - ), + ], + const SizedBox(height: 8), + _PaymentDetails(form: form, controller: controller), const SizedBox(height: 24), - ], - if (!form.isEditing) - FilledButton.icon( - onPressed: form.saving || currency == null - ? null - : () => _record(context, ref), - icon: const Icon(Icons.check), - label: const Text('Record this payment'), + if (!form.isEditing && + currency != null && + currency.code == 'INR') ...[ + _UpiSection( + payeeName: payeeName, + // A new payee, or their handle arriving with their profile, + // is a new field: what it shows is never left over from + // somebody else. + fieldKey: ValueKey((to, form.knownHandleIn(ledger))), + handle: form.handleIn(ledger), + onHandleChanged: to == null + ? null + : controller.setHandle, + uri: buildUpiPaymentUri( + payeeVpa: form.handleIn(ledger), + payeeName: payeeName, + amountMinor: + currency.parseToMinor(form.amount) ?? 0, + currency: currency, + ), + onPaid: () => _record(context, ref), + ), + const SizedBox(height: 24), + ], + if (!form.isEditing) + FilledButton.icon( + onPressed: form.saving || currency == null + ? null + : () => _record(context, ref), + icon: const Icon(Icons.check), + label: const Text('Record this payment'), + ), + const SizedBox(height: 12), + Text( + 'OpenSplit does not move or check money. Recording a payment ' + 'here only updates the balances in this app.', + style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: 12), - Text( - 'OpenSplit does not move or check money. Recording a payment ' - 'here only updates the balances in this app.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + ], + ), ), ), - ), - if (editing != null) ...[ - const SizedBox(height: 24), - EntryHistory(entryId: editing.id, ledger: ledger), + if (editing != null) ...[ + const SizedBox(height: 24), + EntryHistory(entryId: editing.id, ledger: ledger), + ], ], - ], + ), ), ), ); diff --git a/lib/presentation/widgets/export_button.dart b/lib/presentation/widgets/export_button.dart index 336f356..7eaacdd 100644 --- a/lib/presentation/widgets/export_button.dart +++ b/lib/presentation/widgets/export_button.dart @@ -12,7 +12,12 @@ import '../../domain/export/json_export.dart'; /// Exports the group, in whichever of the two formats is wanted. class ExportButton extends ConsumerStatefulWidget { - const ExportButton({super.key, required this.groupId}); + const ExportButton({super.key, required this.groupId}) : _asTile = false; + + /// The same action as a full settings row. + const ExportButton.tile({super.key, required this.groupId}) : _asTile = true; + + final bool _asTile; final String groupId; @@ -28,31 +33,34 @@ class _ExportButtonState extends ConsumerState { Future _pick() async { final format = await showModalBottomSheet<_Format>( context: context, + isScrollControlled: true, builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.table_chart_outlined), - title: const Text('Spreadsheet (CSV)'), - subtitle: const Text( - 'One row per expense, matching what is on screen now. Opens ' - 'in Excel, Numbers or Sheets.', + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.table_chart_outlined), + title: const Text('Spreadsheet (CSV)'), + subtitle: const Text( + 'One row per expense, matching what is on screen now. Opens ' + 'in Excel, Numbers or Sheets.', + ), + isThreeLine: true, + onTap: () => Navigator.of(context).pop(_Format.csv), ), - isThreeLine: true, - onTap: () => Navigator.of(context).pop(_Format.csv), - ), - ListTile( - leading: const Icon(Icons.data_object), - title: const Text('Full backup (JSON)'), - subtitle: const Text( - 'The whole group, ignoring any filter: everyone, every split, ' - 'the exchange rates used, and the activity log.', + ListTile( + leading: const Icon(Icons.data_object), + title: const Text('Full backup (JSON)'), + subtitle: const Text( + 'The whole group, ignoring any filter: everyone, every split, ' + 'the exchange rates used, and the activity log.', + ), + isThreeLine: true, + onTap: () => Navigator.of(context).pop(_Format.json), ), - isThreeLine: true, - onTap: () => Navigator.of(context).pop(_Format.json), - ), - ], + ], + ), ), ), ); @@ -153,15 +161,29 @@ class _ExportButtonState extends ConsumerState { } @override - Widget build(BuildContext context) => IconButton( - tooltip: 'Export', - onPressed: _busy ? null : _pick, - icon: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.ios_share), - ); + Widget build(BuildContext context) => widget._asTile + ? ListTile( + leading: const Icon(Icons.ios_share), + title: const Text('Export this group'), + subtitle: const Text('Download a spreadsheet or full backup.'), + isThreeLine: true, + onTap: _busy ? null : _pick, + trailing: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + ) + : IconButton( + tooltip: 'Export', + onPressed: _busy ? null : _pick, + icon: _busy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.ios_share), + ); } diff --git a/lib/presentation/widgets/form_leave_guard.dart b/lib/presentation/widgets/form_leave_guard.dart new file mode 100644 index 0000000..2128787 --- /dev/null +++ b/lib/presentation/widgets/form_leave_guard.dart @@ -0,0 +1,68 @@ +import 'package:material_ui/material_ui.dart'; + +import '../navigation.dart'; + +/// Protects a form's changed draft with the standard discard dialog. +/// +/// [draft] must have value equality and omit transient saving/error state. +/// Successful saves can navigate directly; [builder]'s callback is for Close +/// and Back, which must also respect an in-flight save. +class FormLeaveGuard extends StatefulWidget { + const FormLeaveGuard({ + super.key, + required this.draft, + required this.saving, + required this.fallback, + required this.builder, + }); + + final Object draft; + final bool saving; + final String fallback; + final Widget Function(BuildContext context, VoidCallback leave) builder; + + @override + State createState() => _FormLeaveGuardState(); +} + +class _FormLeaveGuardState extends State { + late final Object _original = widget.draft; + bool _asking = false; + + bool get _changed => widget.draft != _original; + + Future _leave() async { + if (widget.saving || _asking) return; + if (_changed) { + _asking = true; + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard changes?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Keep editing'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Discard'), + ), + ], + ), + ); + _asking = false; + if (discard != true) return; + } + if (mounted && !widget.saving) goBack(context, widget.fallback); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_changed && !widget.saving, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _leave(); + }, + child: widget.builder(context, _leave), + ); +} diff --git a/lib/presentation/widgets/group_cover.dart b/lib/presentation/widgets/group_cover.dart index 92b91da..316e957 100644 --- a/lib/presentation/widgets/group_cover.dart +++ b/lib/presentation/widgets/group_cover.dart @@ -35,6 +35,12 @@ class GroupCover extends ConsumerWidget { final group = ledger.group; final avatar = group.avatar; final scheme = AvatarPalette.of(context).forAvatar(avatar, group.id); + final surface = Theme.of(context).colorScheme.surfaceContainerHigh; + // Money and supporting text can wrap across the entire pattern. Keep + // every motif quiet beneath them, including at the trailing edge. + final quietOpacity = Theme.of(context).brightness == Brightness.light + ? 0.07 + : 0.08; final categories = { for (final category in ref.watch(categoriesProvider).value ?? const []) @@ -62,23 +68,11 @@ class GroupCover extends ConsumerWidget { return ExcludeSemantics( child: ColoredBox( - color: Color.lerp( - scheme.primaryContainer, - scheme.surfaceContainerLow, - patternFade, - )!, + color: Color.lerp(scheme.primaryContainer, surface, patternFade)!, child: IconTheme.merge( data: IconThemeData(color: scheme.onPrimaryContainer), - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => LinearGradient( - colors: [ - Colors.black.withValues(alpha: 0.16 * (1 - 0.65 * patternFade)), - Colors.black.withValues(alpha: 0.16 * (1 - 0.65 * patternFade)), - Colors.black.withValues(alpha: 0.16 * (1 - 0.2 * patternFade)), - ], - stops: const [0, 0.6, 1], - ).createShader(bounds), + child: Opacity( + opacity: 0.16 + (quietOpacity - 0.16) * patternFade, child: ClipRect( child: FittedBox( fit: BoxFit.cover, diff --git a/lib/presentation/widgets/group_link_sheet.dart b/lib/presentation/widgets/group_link_sheet.dart index 2a8c671..afc4156 100644 --- a/lib/presentation/widgets/group_link_sheet.dart +++ b/lib/presentation/widgets/group_link_sheet.dart @@ -166,7 +166,7 @@ class _GroupLinkSheetState extends ConsumerState<_GroupLinkSheet> { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, + color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: SelectableText(_url, style: theme.textTheme.bodySmall), diff --git a/lib/presentation/widgets/group_search_button.dart b/lib/presentation/widgets/group_search_button.dart new file mode 100644 index 0000000..d779204 --- /dev/null +++ b/lib/presentation/widgets/group_search_button.dart @@ -0,0 +1,177 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:opensplit_api/opensplit_api.dart' show EntryKind; + +import '../../application/ledger_providers.dart'; +import '../../data/local/database.dart'; +import '../../domain/models/entry.dart'; +import '../../domain/money_format.dart'; + +/// Opens Material's search view over a group's local expenses and payments. +class GroupSearchButton extends StatelessWidget { + const GroupSearchButton({super.key, required this.groupId}); + + final String groupId; + + @override + Widget build(BuildContext context) { + return SearchAnchor( + viewHintText: 'Search descriptions and notes', + builder: (context, controller) => IconButton( + tooltip: 'Search expenses and payments', + icon: const Icon(Icons.search), + onPressed: controller.openView, + ), + // The result list is lazy and watches Drift while the search view is open. + viewBuilder: (children) => Builder( + builder: (context) => SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: children.firstOrNull ?? const SizedBox.shrink(), + ), + ), + ), + suggestionsBuilder: (_, controller) => [ + _SearchResults( + groupId: groupId, + query: controller.text, + onSelected: (entry) { + controller.closeView(null); + GoRouter.of(context).push('/g/$groupId/e/${entry.id}'); + }, + ), + ], + ); + } +} + +class _SearchResults extends ConsumerWidget { + const _SearchResults({ + required this.groupId, + required this.query, + required this.onSelected, + }); + + final String groupId; + final String query; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ledger = ref.watch(groupLedgerProvider(groupId)); + final currencies = ref.watch(currenciesProvider).value ?? const {}; + if (ledger == null || ledger.me?.isActive != true) { + return const _SearchMessage('This group is no longer available.'); + } + if (query.trim().isEmpty) { + return const _SearchMessage( + 'Search descriptions and notes across this group’s expenses and payments.', + ); + } + final provider = groupSearchResultsProvider(groupId, query); + final results = ref.watch(provider); + return results.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => _SearchMessage( + 'Couldn’t search this group.', + action: TextButton( + onPressed: () => ref.invalidate(provider), + child: const Text('Try again'), + ), + ), + data: (entries) => entries.isEmpty + ? const _SearchMessage('No matching expenses or payments.') + : ListView.builder( + key: ValueKey(query), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.zero, + itemCount: entries.length, + itemBuilder: (context, index) => _SearchResultTile( + entry: entries[index], + ledger: ledger, + currency: currencies[entries[index].row.currency], + onTap: () => onSelected(entries[index]), + ), + ), + ); + } +} + +class _SearchResultTile extends StatelessWidget { + const _SearchResultTile({ + required this.entry, + required this.ledger, + required this.currency, + required this.onTap, + }); + + final Entry entry; + final GroupLedger ledger; + final Currency? currency; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final payment = entry.row.kind == EntryKind.settlement; + final payers = entry.payers.map((payer) => ledger.nameOf(payer.memberId)); + final recipients = entry.shares.map( + (share) => ledger.nameOf(share.memberId), + ); + final title = payment + ? '${payers.join(', ')} paid ${recipients.join(', ')}' + : entry.row.description.isEmpty + ? 'Expense' + : entry.row.description; + final notes = entry.row.notes?.trim(); + final amount = formatMoney( + currency, + entry.row.amountMinor, + withSymbol: false, + ); + return ListTile( + onTap: onTap, + leading: Icon( + payment ? Icons.handshake_outlined : Icons.receipt_long_outlined, + ), + title: Text(title), + subtitle: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (notes != null && notes.isNotEmpty) + Text(notes, maxLines: 2, overflow: TextOverflow.ellipsis), + Text( + '${DateFormat.yMMMd().format(entry.row.entryDate)} · ' + '${entry.row.currency} $amount', + ), + ], + ), + ); + } +} + +class _SearchMessage extends StatelessWidget { + const _SearchMessage(this.message, {this.action}); + + final String message; + final Widget? action; + + @override + Widget build(BuildContext context) => Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message, textAlign: TextAlign.center), + ?action, + ], + ), + ), + ); +} diff --git a/lib/presentation/widgets/inline_notice.dart b/lib/presentation/widgets/inline_notice.dart index a0d9ffe..628a48b 100644 --- a/lib/presentation/widgets/inline_notice.dart +++ b/lib/presentation/widgets/inline_notice.dart @@ -27,7 +27,7 @@ class InlineNotice extends StatelessWidget { final foreground = isError ? scheme.onErrorContainer : scheme.onSurface; return Card.filled( - color: isError ? scheme.errorContainer : null, + color: isError ? scheme.errorContainer : scheme.surfaceContainerHigh, child: Padding( padding: const EdgeInsets.all(16), child: Column( diff --git a/lib/presentation/widgets/invite_sheet.dart b/lib/presentation/widgets/invite_sheet.dart index 4536249..19b351c 100644 --- a/lib/presentation/widgets/invite_sheet.dart +++ b/lib/presentation/widgets/invite_sheet.dart @@ -114,7 +114,7 @@ class _InviteSheetState extends ConsumerState<_InviteSheet> { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, + color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: SelectableText( diff --git a/lib/presentation/widgets/segmented_list.dart b/lib/presentation/widgets/segmented_list.dart index 2fcf31c..b74636f 100644 --- a/lib/presentation/widgets/segmented_list.dart +++ b/lib/presentation/widgets/segmented_list.dart @@ -58,7 +58,9 @@ class Segment extends StatelessWidget { return Padding( padding: EdgeInsets.only(top: first ? 0 : gap), child: Material( - color: Theme.of(context).colorScheme.surfaceContainer, + // The higher container stays distinct even when Android supplies a + // darker surface role close to surfaceContainerLow. + color: Theme.of(context).colorScheme.surfaceContainerHigh, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: radiusAt(index: index, count: count), diff --git a/pubspec.yaml b/pubspec.yaml index 4f2d311..0e3cb57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ publish_to: "none" # never by hand (see "Versions" in docs/runbook.md). There is no `+build` # here on purpose. The release workflow numbers every build itself, and a # number written here would only be a second, stale answer. -version: 4.0.0 +version: 4.1.0 environment: sdk: ^3.12.2 diff --git a/site/store/artifacts.json b/site/store/artifacts.json index babc4b5..4f94702 100644 --- a/site/store/artifacts.json +++ b/site/store/artifacts.json @@ -29,8 +29,8 @@ "lib/application/entry_form.freezed.dart": "4dfda75c5afd4ab7eaf1c555874eac2039553f130ab0685e3fb30ba5b47ab17b", "lib/application/entry_form.g.dart": "7648f069d7f8fcbac389dffc238ea44b11ae3b94df5a7d70a85a588d610a4856", "lib/application/entry_notification.dart": "6ef66e02cfe695f577e3692831220495418ae02357debcdd2004443f8fa256bc", - "lib/application/ledger_providers.dart": "2898d194a495d14a46fe02b119c316a985facdc1060f6f1006589f4bf16e92ea", - "lib/application/ledger_providers.g.dart": "a3a7e791780c2138e2e78415962eeb94e947bd474de1991a56e7c7ad63c07046", + "lib/application/ledger_providers.dart": "c0a0b3ade0ec4d093f39286207eda7a694ef0880428c3c62c40a97920e359248", + "lib/application/ledger_providers.g.dart": "9caba024161d821dd623156da94cddc40610b3b2cc6b22a9f2a7c613956d76e9", "lib/application/local_providers.dart": "9f378ab238aea22e521b6fac335c29edbd65ecfc5f7c90529d9c7aa893a487f2", "lib/application/local_providers.g.dart": "fabee2659a35c1e6eb0d1071ed932e35aba3343fdfd79dd12db196d6d6d36915", "lib/application/preferences_providers.dart": "adbc1e24f8f13b370bd01285a6c11deb9a5e6f5186d580f87366507aa83d2131", @@ -58,6 +58,7 @@ "lib/data/fx/drift_fx_repository.dart": "65ebb5ffc047874d77aa15325b8b4b2088ecd541f4b1232652eace4430ca5398", "lib/data/local/database.dart": "6e40d2e8e754be58299666e46838b78748a8651ab5acd7413460cc7a8381ba93", "lib/data/local/database.drift.dart": "00f398408f1bdd308e285c859fd864ac505f980cda6f55c8461016671fa71437", + "lib/data/local/entry_search.dart": "2072f61be445debdf042390069e6c3d520d1e8ccf9d98ec59af1fc189c9164f3", "lib/data/local/entry_writer.dart": "01fe95604ad860094d34015168e7627f7536a1c697ffffa082cf87dac18d0183", "lib/data/local/local_reset.dart": "2032e3fa1a9ec575d910e66fc81ba26b7f4322b822c273e9ef407c5068157b0b", "lib/data/local/open_database.dart": "590bc59b515cad19a8b3bc6166fe64b841d115fe18076aec1fb49f54af9281f8", @@ -79,11 +80,11 @@ "lib/data/push/web_push_bridge_stub.dart": "13ddd4a9055b3b84f85662aa986f2b2067cd3f3f64e2960e3b26b3964d5866c0", "lib/data/push/web_push_bridge_web.dart": "7659eba81f58f546038c7bf0471874df476094bc3b0f1ea996a0b01473a23bea", "lib/data/repositories/drift_activity_repository.dart": "a82e074b0f8cbdbb83d893a455eeafa5a2a055b126b96855f8da0c46afd1e911", - "lib/data/repositories/drift_analytics_repository.dart": "ed581b371221f2172dac7138139d1ebc8135d76b9a1d51650bf3dec546f6fe43", + "lib/data/repositories/drift_analytics_repository.dart": "fa40a5ed859401b39c4959952f0d75d25163b021f623f8217df399db0d2e2698", "lib/data/repositories/drift_category_repository.dart": "3319560a71542e3c090d35a5e5d8e504ae0d609ea22007477ce7ac8b6f9f1d88", "lib/data/repositories/drift_conflict_repository.dart": "40c2e243a65967c457d1c82c3c6d4c1d0f1aaf5277e18d97439b9e888284ac66", "lib/data/repositories/drift_currency_repository.dart": "3e9fcdddf8ee6dd439c873da4d58fc3cda12f4c45fdb9ee6c9b5f6891c0e5cce", - "lib/data/repositories/drift_entry_repository.dart": "706e46afcccf80119f795340c78ca0d9a4f2a93453a5e6eaa3861c643ba20ec7", + "lib/data/repositories/drift_entry_repository.dart": "14cf75d57374d6af0854ec80ded6df60cba408acd40dfa70095f6d6bc2223066", "lib/data/repositories/drift_group_repository.dart": "0d3c1f666e1ffc60b75d9ce0ef6d51e6c906505171a9b56bdb2d9eb0c09bbdbd", "lib/data/repositories/drift_profile_repository.dart": "50ac9ff0a31f55902a10269521020135e1cef825a6ba26155e82f403f8b97fe3", "lib/data/repositories/group_write_access.dart": "6a87d81b3bd513a472fc6eb4b782f2e93939ddd7aa26c1c78cab427689efc2ff", @@ -152,21 +153,21 @@ "lib/presentation/navigation.dart": "712c335db563e899ec9b0df6c8e41b2e0e97c6887699d8a089df5b9bba2b516f", "lib/presentation/router.dart": "28b608919ac16459f2ae7be9ab87a3e8842eff3f1eb45d6415c9fbf90dc7dd92", "lib/presentation/screens/about_screen.dart": "e9794aa89dde035726449f64cdba59c2b87b386b5ca4fa017ae204b0ab253221", - "lib/presentation/screens/account_screen.dart": "3ae90e5a75385b6cb50a90111356353a2dbc55af05ca33e1594ea61e4fbc742f", + "lib/presentation/screens/account_screen.dart": "2c30b942bb7c4769fce39ddcd3e5217d746d500fa633afa1bff693d979b3f12c", "lib/presentation/screens/activity_screen.dart": "9602da6e31a740af63c5abc756253b95a5506dec068598b32c202cc95e488544", "lib/presentation/screens/avatar_picker_screen.dart": "de23892244dcf40d7636d663e2f3aabe0ad7229cd466110e840be80e9895faca", "lib/presentation/screens/edit_profile_screen.dart": "5e8068b7d73b7890a9eca287c584c0f62a3d5a274aaf10069c4e137c52d3b822", - "lib/presentation/screens/entry_editor_screen.dart": "9699ecad8d7fe6495cdfe67eca9a20a9a497b5d3d9deedce8413378678eab82c", - "lib/presentation/screens/group_detail_screen.dart": "e0ac5ebd357ce6443b8a538259d22da0978c43dafb5ff293dd80310e028e21b8", - "lib/presentation/screens/group_list_screen.dart": "623f41632bbe5c739969c10b0f64b1d2f358525f74ab469e5fda1ac318ca8cbf", - "lib/presentation/screens/group_settings_screen.dart": "784d499c6d21264ee09305e7b0ddc7eb9a7543d1cfb18ee6054c6a71d329dec8", - "lib/presentation/screens/insights_screen.dart": "7101c8187130ef0e1051eedb07eabee3f182313db0979a9bc575dc028d60f320", + "lib/presentation/screens/entry_editor_screen.dart": "7770c2c08a2fd2808cd0c4e79f8cd2d6d6883338fe187068650f3b4cf6666032", + "lib/presentation/screens/group_detail_screen.dart": "a17fb331d495795d2b983e732cfc9acfaf5685bfac4b61bddc464a42b5642bd4", + "lib/presentation/screens/group_list_screen.dart": "0c86bbecf20d4b808365bcfc2e59c98938ee73d4c77d57b952e3eff9f5e2fc40", + "lib/presentation/screens/group_settings_screen.dart": "60b42b7dbf0124305bad55c6bc428242400a720ddad80b1a9f9ba6f604c6c27a", + "lib/presentation/screens/insights_screen.dart": "84d38f952a1f06491b1cd97c6a267df84ae8dc89a7d59baf3d05fb0bf0e07102", "lib/presentation/screens/join_screen.dart": "be9c52b83d3474312c06afc43ed39e39cca92597fd1b5fdd79b4b2cf61250b33", "lib/presentation/screens/members_screen.dart": "8d5dba64a725e24f37207d29cd013b887ae883b497fd8bd94f3eb209b580da56", "lib/presentation/screens/not_found_screen.dart": "cb821cc04137707c42669af36b6fbc58d57577ad7cc7cbe2738daa560a25d7fb", "lib/presentation/screens/save_account_screen.dart": "1339fbd7ae209d399374b3e80e7d6b892fec0f19666b1d996bc41e6513ce7a0b", - "lib/presentation/screens/settings_screen.dart": "20483de7afe1dce228a0f3f1dd241f554b3a7807e2b40d568d79ed22268de232", - "lib/presentation/screens/settle_up_screen.dart": "e002f7c9f18d0ee95f56e219ba1164b8052caf2ff25d7d4a7e72952eb49d9de6", + "lib/presentation/screens/settings_screen.dart": "8ca51564117db46151ee5dc87c98e3b9d4843785d00b19d2da507223718e7971", + "lib/presentation/screens/settle_up_screen.dart": "09825802e7c2c870d538f7f1a9195233dcd30e08f66421478619670352642397", "lib/presentation/screens/welcome_screen.dart": "321acbee2761400906b314fda5e88b5315753a867c90b530211f251de33e43fa", "lib/presentation/theme.dart": "c8f9e39293ddb575f1e4c0f7f9170d30e11b597610adb7569485c385fa42819f", "lib/presentation/theme_mode.dart": "17a62bcefcb465a5b7e84e35beda4d7e8ed5a5e062ad6eb2ca68b772f0f144bf", @@ -181,16 +182,18 @@ "lib/presentation/widgets/currency_picker.dart": "96143253b29f35d621732c949c21ea19ef1456f9d046f5fab83698b4b4806b79", "lib/presentation/widgets/empty_state.dart": "5a0b249bc19037f633f226863af8ab848589c8fd0beedc704b97568080c283e7", "lib/presentation/widgets/entry_history.dart": "d836ef8febbb1a1b8b619e36cdf74d399de9f08cac8d276f2804bdf9cbfc25b6", - "lib/presentation/widgets/export_button.dart": "4c75072742ea7bc950dc435c271c906dda5e8cd50b0a3617ab2ddf75b7b289fe", + "lib/presentation/widgets/export_button.dart": "38f1cfaf51430c4cfb106d481d1def3653d3dfd34ffbe84202c68bf84614a70f", + "lib/presentation/widgets/form_leave_guard.dart": "a090e7dcaa6729ef7b7d1cd28f0d6ebf4a7e02a3aaabbca8133aa4b3c305f9d9", "lib/presentation/widgets/group_access.dart": "7de6f76f3d4994458427e5c6ade54c0dc202d457eccad13627d6852d470d35dd", "lib/presentation/widgets/group_avatar_hero.dart": "04859ff74e711969ba921c62a24db1d08464851a798d182f4c044a8f4707bf62", - "lib/presentation/widgets/group_cover.dart": "88379edff8ff23b5c3edb328a522dc78832c32e13a4f6a26d7ae085519b08cae", - "lib/presentation/widgets/group_link_sheet.dart": "663df65b3ef61848cb2a829e0d3c4daec284b1a8b456e40fa1e4be1b320944c6", + "lib/presentation/widgets/group_cover.dart": "dd6987925e13d3a1c4fc868e7434c562dd8a812bd9d1c3f0da669f8a93c881f3", + "lib/presentation/widgets/group_link_sheet.dart": "1e5e97451a0964ae1c8f9cc0fb036502e8d8d2702a19f81c94f88a84d793056d", "lib/presentation/widgets/group_pane.dart": "cee8402015a2fc524d257744ad985178b841ef7b2ced7ffa37a17a945dc3cc02", + "lib/presentation/widgets/group_search_button.dart": "2f03b3c3b8d1d20f4deea610afc6b0a5df96b6edbc5ce4adb664ddc4a72ad836", "lib/presentation/widgets/group_standing.dart": "437db1ebce51f4ac88a23a0ad069ad7ffe004897c734c7d8c532df399e8d4412", "lib/presentation/widgets/identity_choices.dart": "37bca8aabc8d10e47673776af51ba5f2fb226140849e7b24c7cce00b27afdb0b", - "lib/presentation/widgets/inline_notice.dart": "95f8854ac4b50b993d52383882b8e19c9ca947e3dcc5547f42ab9a562c6f4093", - "lib/presentation/widgets/invite_sheet.dart": "b35dbafa05d762ccb6bba9b7726f2fdf87ce0fe8ec9901e2e216b68b30215cfb", + "lib/presentation/widgets/inline_notice.dart": "6ab80bed705b3346d0fd52abfcc474da349d20daa9177fae9f00c1f2a84b4aee", + "lib/presentation/widgets/invite_sheet.dart": "b9f9c6f93b7a2b9955d4f3fbef3ecaed96009bd419542cdcf32124a5a2cd75c6", "lib/presentation/widgets/link_account_prompt.dart": "da71ba86f2d43094064840d3c726dc39c97394a82a9de23ef002d255bf25e967", "lib/presentation/widgets/notification_invitation.dart": "001d8a29701307f89369a977befc269b30ebbcd0c1f7c08e42289fe6388e0d89", "lib/presentation/widgets/offline_indicator.dart": "c3af01e54632c0ac780d17c459b9b8e26b8016f183fd29952971d8ef6d451c92", @@ -198,7 +201,7 @@ "lib/presentation/widgets/pull_to_sync.dart": "2eea75f6426e6f2781ac49e13246f4f864dcd5743ae01763d3c6d10172bf4b24", "lib/presentation/widgets/remainder_field.dart": "3301ef986b4b013b33776d0b5e2a94b7384a475be9ca4bfc9fc3a52451e55b44", "lib/presentation/widgets/save_account_form.dart": "2947921f879cbdb354020e636edb3d176087f3b39e24d89367aac3ddea436655", - "lib/presentation/widgets/segmented_list.dart": "fd3316f33853a637179a6f7fb32b0bb419975913c9e7c134ec96f9303f316efc", + "lib/presentation/widgets/segmented_list.dart": "3b60237135494c6e20a50c0267c6f06baa23d69274520044e121b363999984e2", "lib/presentation/widgets/shared_element.dart": "56c484a3c6cdd176b4aa2ac8c37eca0c6909309ac8dc539219dba275605832cc", "lib/presentation/widgets/sync_refresh_button.dart": "126a9ba1adb2dcce727f42290c914db0f9e5959eea6079fc5d3cc03934d969fa", "lib/presentation/widgets/sync_status_notice.dart": "805cab938e21ee94e612e2dc4e460b7d19359f28a9f83ea9a2ed6232a82f6e83", @@ -347,22 +350,23 @@ "packages/opensplit_api/lib/src/model/share.g.dart": "17ff331c72c72242b56731122cfbde7b2a603e810d28bf58ee0f135e252c77a2", "packages/opensplit_api/lib/src/model/split_kind.dart": "38186b7b070123737b5d78cb5f19e53c0d77d95b3ffe78f2654bbaffb23851a4", "pubspec.lock": "04d8d141c6813eb17eba0f94235b701503a2337ab12b022826476a92d3ae21ca", - "pubspec.yaml": "015a4d60b2b44250fa2dfa64436876c1cb00bf700daec0e482e08ab04133a892", + "pubspec.yaml": "c4aef83b201421f0598c4bc626c8d37df1243412f0a2ac8e6219ce54316e43a6", "test/data/server_reference_data.dart": "b291827d21d1ff0bee36bc7ef86e6ff1618f767e20deb1df796ac911bf6e6b9c", + "test/fixtures/android_wallpaper.dart": "588b5037842a5c6ab2f4842e47b679b6d9de3f16c7f4670c2c125af707633c84", "test/harness.dart": "0e85c632a2251eb3f8052c9d6ed11b6a5e56ee1a63faf472c045c577cab53387", - "tool/screenshots_test.dart": "5046fa6c017640ee61cdfacb5d950bfae1c3088ad23bce588830c6a52408932a", - "tool/store_artifacts.dart": "573f637363967a67529ce1a33ca4e6178eb6fc9a388b751036653e53e0da14ad", + "tool/screenshots_test.dart": "2189a830a722421541ea5a3b05a8ce4c47faaf40c7a2ba9f9793cd1c69aefc09", + "tool/store_artifacts.dart": "27f3a80dac46eac9070af6c6109f20d37c23ded0e1a912eac9625644b2db1c2c", "tool/store_graphics.py": "bc24ac3657fe43d145a5876c000357a69c272d54c73c6b32bb65f47d052ed1d7" }, "outputs": { - "site/store/screenshot-1-groups.png": "83016de59d5a3d5ed49c8ca7204c7cf7460d1aae82fb9e65bbc7d5efa35cb37e", - "site/store/screenshot-2-expenses.png": "e51019038c599382851ebe03ed6fa8290bf5067090d3d4adc20760d8f0bcc029", - "site/store/screenshot-3-balances.png": "cd6fda7d692d2e40cffb970aa9cb6d4a91f97247a06633c847413fd3916615af", - "site/store/screenshot-4-add-expense.png": "0f1d894b2c169e713dc059830e641eb5c4df47ebf007c64469aa629d1ea34d22", - "site/store/screenshot-5-insights.png": "de620ebe0761bd9a2019d70349c6660236fab8d942375db5fd49baa00c456a33", - "site/store/web-app.png": "ef59f4dc9d9197ae0e802abb6b2818d388da835493a2ec891802cfaafe01e163", - "site/store/og-card.png": "4276a3086db2e40680908ad59e69ab8760e6d7f5211884320b93fafb288dc293", - "site/store/feature-graphic.jpg": "39169a7cfab4f3e2e4241553dc543542dd1955f3018d1ae1b8b96b5d6585715e", + "site/store/screenshot-1-groups.png": "5a423c4ee53d798e8cc5c3d6c88c7c0463537b9496a5c3af209131e9a0adaa75", + "site/store/screenshot-2-expenses.png": "bf13ae85570f7b0fc498097d714aa8a4ac61a49c663cd4143708fd596f453365", + "site/store/screenshot-3-balances.png": "cf8b93928e508999a1996c9bf1a763aee59ec7b1539540cb57406d6359945915", + "site/store/screenshot-4-add-expense.png": "d4ca87fb76b9cffbf8fcbd53d4064b669802f5e55d1a099aeaf4447f9ae43027", + "site/store/screenshot-5-insights.png": "876fe78c5ce4952a58b27fc3171b3fed13fc0341fa0d0234413f27fc61ca30a9", + "site/store/web-app.png": "932618a787d758163203b922fc193157432f6b033f7a3d6b5f0879b768181925", + "site/store/og-card.png": "b0a268e14bcdf8d3466d0bcb030ea9580994afa9b7737cd4e70347557a8e62e5", + "site/store/feature-graphic.jpg": "0bd2040b384175626d695087aabc4771962201c3c5fe51a06ba7d0a570185a0d", "site/email/wordmark.png": "8c43232387a3464261b7b86957495f972a1b4540d122d0cbfb33e5b18ad50b4c" } } diff --git a/site/store/feature-graphic.jpg b/site/store/feature-graphic.jpg index fba0d88..a4f0206 100644 Binary files a/site/store/feature-graphic.jpg and b/site/store/feature-graphic.jpg differ diff --git a/site/store/og-card.png b/site/store/og-card.png index bf48a13..1dbe2e3 100644 Binary files a/site/store/og-card.png and b/site/store/og-card.png differ diff --git a/site/store/screenshot-1-groups.png b/site/store/screenshot-1-groups.png index 0d7d27a..80666d8 100644 Binary files a/site/store/screenshot-1-groups.png and b/site/store/screenshot-1-groups.png differ diff --git a/site/store/screenshot-2-expenses.png b/site/store/screenshot-2-expenses.png index 20784ae..9328a9f 100644 Binary files a/site/store/screenshot-2-expenses.png and b/site/store/screenshot-2-expenses.png differ diff --git a/site/store/screenshot-3-balances.png b/site/store/screenshot-3-balances.png index 32a7cc1..5747e93 100644 Binary files a/site/store/screenshot-3-balances.png and b/site/store/screenshot-3-balances.png differ diff --git a/site/store/screenshot-4-add-expense.png b/site/store/screenshot-4-add-expense.png index c920a4d..01cc057 100644 Binary files a/site/store/screenshot-4-add-expense.png and b/site/store/screenshot-4-add-expense.png differ diff --git a/site/store/screenshot-5-insights.png b/site/store/screenshot-5-insights.png index 1f26cef..a1be8a7 100644 Binary files a/site/store/screenshot-5-insights.png and b/site/store/screenshot-5-insights.png differ diff --git a/site/store/web-app.png b/site/store/web-app.png index d7dc63d..bc79c87 100644 Binary files a/site/store/web-app.png and b/site/store/web-app.png differ diff --git a/test/data/entry_search_test.dart b/test/data/entry_search_test.dart new file mode 100644 index 0000000..4baec48 --- /dev/null +++ b/test/data/entry_search_test.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/data/repositories/drift_entry_repository.dart'; +import 'package:opensplit/data/repositories/drift_group_repository.dart'; +import 'package:opensplit/domain/entry_draft.dart'; +import 'package:opensplit/domain/models/entry.dart'; +import 'package:opensplit/domain/split/splitter.dart'; +import 'package:test/test.dart'; + +import '../harness.dart'; + +void main() { + late AppDatabase db; + late DriftEntryRepository entries; + late String groupId; + late String ana; + late String maya; + + setUp(() async { + db = await testDatabase(); + entries = DriftEntryRepository(db); + final groups = DriftGroupRepository(db); + final created = await groups.createGroup( + name: 'Trip', + defaultCurrency: 'INR', + creatorDisplayName: 'Ana', + ); + groupId = created.group.id; + ana = created.creator.id; + maya = (await groups.addMember(groupId, displayName: 'Maya')).id; + }); + tearDown(() => db.close()); + + EntryDraft expense( + String description, { + String currency = 'INR', + String? notes, + }) => EntryDraft( + groupId: groupId, + currency: currency, + amountMinor: 10000, + description: description, + notes: notes, + split: EqualSplit([ana, maya]), + payerAmounts: {ana: 10000}, + ); + + test( + 'search spans currencies and payments, excludes other groups and deleted rows', + () async { + final coffee = await entries.create(expense('Coffee'), createdBy: ana); + final tokyo = await entries.create( + expense('Coffee in Tokyo', currency: 'JPY'), + createdBy: ana, + ); + final payment = await entries.create( + EntryDraft.settlement( + groupId: groupId, + currency: 'INR', + amountMinor: 5000, + fromMemberId: maya, + toMemberId: ana, + notes: 'Paid back for coffee', + ), + createdBy: maya, + ); + final deleted = await entries.create( + expense('Coffee deleted'), + createdBy: ana, + ); + await entries.delete(deleted.id, actorId: ana); + final other = await DriftGroupRepository(db).createGroup( + name: 'Other', + defaultCurrency: 'INR', + creatorDisplayName: 'Ana', + ); + await entries.create( + EntryDraft( + groupId: other.group.id, + currency: 'INR', + amountMinor: 5000, + description: 'Coffee elsewhere', + split: EqualSplit([other.creator.id]), + payerAmounts: {other.creator.id: 5000}, + ), + createdBy: other.creator.id, + ); + + expect( + (await entries.search(groupId, 'COF').first).map((entry) => entry.id), + unorderedEquals([coffee.id, tokyo.id, payment.id]), + ); + expect( + (await entries.search(groupId, 'back cof').first).single.id, + payment.id, + ); + expect(await entries.search(groupId, ' ').first, isEmpty); + for (final text in ['"', '*', 'NEAR(', 'a AND']) { + await expectLater(entries.search(groupId, text).first, completes); + } + }, + ); + + test('open search reacts to inserts, edited notes, and deletion', () async { + final pending = StreamIterator>(entries.search(groupId, 'cof')); + addTearDown(pending.cancel); + Future> next() async { + expect(await pending.moveNext(), isTrue); + return pending.current; + } + + expect(await next(), isEmpty); + final row = await entries.create( + expense('Dinner', notes: 'Coffee included'), + createdBy: ana, + ); + expect((await next()).single.id, row.id); + await entries.update( + row.id, + expense('Dinner', notes: 'Tea included'), + actorId: ana, + ); + expect(await next(), isEmpty); + await entries.update( + row.id, + expense('Dinner', notes: 'Coffee included'), + actorId: ana, + ); + expect((await next()).single.id, row.id); + await entries.delete(row.id, actorId: ana); + expect(await next(), isEmpty); + }); +} diff --git a/test/fixtures/android_wallpaper.dart b/test/fixtures/android_wallpaper.dart new file mode 100644 index 0000000..4ca6ca7 --- /dev/null +++ b/test/fixtures/android_wallpaper.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; + +/// A synthetic teal Android palette, generated by CorePalette.of(0xff006b5f). +/// +/// Uses the native plugin payload (five palettes of thirteen tones) so reviews +/// exercise Android conversion, including its legacy surface role. This is +/// representative test data, not a palette captured from a particular device. +Int32List tealAndroidWallpaper() => Int32List.fromList([ + 0xff000000, + 0xff00201c, + 0xff003731, + 0xff005047, + 0xff006b5f, + 0xff008677, + 0xff00a391, + 0xff2fbfac, + 0xff55dbc7, + 0xff75f8e3, + 0xffb5fff0, + 0xfff3fffb, + 0xffffffff, + 0xff000000, + 0xff06201c, + 0xff1c3530, + 0xff334b47, + 0xff4a635e, + 0xff627c77, + 0xff7c9690, + 0xff96b1aa, + 0xffb1ccc6, + 0xffcde8e1, + 0xffdbf7f0, + 0xfff3fffb, + 0xffffffff, + 0xff000000, + 0xff001e30, + 0xff143349, + 0xff2c4a60, + 0xff456179, + 0xff5d7a93, + 0xff7794ad, + 0xff91afc9, + 0xffaccae5, + 0xffcbe6ff, + 0xffe6f2ff, + 0xfffcfcff, + 0xffffffff, + 0xff000000, + 0xff191c1b, + 0xff2d3130, + 0xff444746, + 0xff5c5f5e, + 0xff747876, + 0xff8e9190, + 0xffa9acaa, + 0xffc4c7c5, + 0xffe0e3e1, + 0xffeff1ef, + 0xfffafdfb, + 0xffffffff, + 0xff000000, + 0xff141d1b, + 0xff293230, + 0xff3f4946, + 0xff56605e, + 0xff6f7976, + 0xff899390, + 0xffa3adaa, + 0xffbec9c5, + 0xffdae5e1, + 0xffe9f3ef, + 0xfff4fffb, + 0xffffffff, +]); diff --git a/test/presentation/entry_editor_test.dart b/test/presentation/entry_editor_test.dart index 4e3ead4..e378c62 100644 --- a/test/presentation/entry_editor_test.dart +++ b/test/presentation/entry_editor_test.dart @@ -151,6 +151,51 @@ void main() { }); tearDown(() => db.close()); + testWidgets('changed expense survives Close and asks before system back', ( + tester, + ) async { + await _seed(db); + await _pumpApp(tester, db); + await _openEditor(tester); + await _type(tester, what: 'Dinner', amount: '300'); + await tester.tap(find.byType(CloseButton)); + await _beats(tester); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Keep editing')); + await _beats(tester); + expect(tester.widget(_field('Amount')).controller!.text, '300'); + await tester.binding.handlePopRoute(); + await _beats(tester); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Discard')); + await _beats(tester); + expect( + find.widgetWithText(FloatingActionButton, 'Add expense'), + findsOneWidget, + ); + expect(await db.select(db.entries).get(), isEmpty); + await _unmount(tester); + }); + + testWidgets('restoring an expense draft leaves without a discard prompt', ( + tester, + ) async { + await _seed(db); + await _pumpApp(tester, db); + await _openEditor(tester); + await tester.enterText(_field('Amount'), '300'); + await tester.enterText(_field('Amount'), ''); + await _beats(tester); + await tester.tap(find.byType(CloseButton)); + await _beats(tester); + expect(find.text('Discard changes?'), findsNothing); + expect( + find.widgetWithText(FloatingActionButton, 'Add expense'), + findsOneWidget, + ); + await _unmount(tester); + }); + testWidgets('save errors remain visible at phone height', (tester) async { await _seed(db); await _pumpApp(tester, db, size: const Size(360, 640)); @@ -160,6 +205,13 @@ void main() { expect(find.byType(SnackBar), findsOneWidget); expect(find.text('Add expense'), findsOneWidget); expect(tester.takeException(), isNull); + await tester.tap(find.byType(CloseButton)); + await _beats(tester); + expect(find.text('Discard changes?'), findsNothing); + expect( + find.widgetWithText(FloatingActionButton, 'Add expense'), + findsOneWidget, + ); await _unmount(tester); }); diff --git a/test/presentation/group_look_test.dart b/test/presentation/group_look_test.dart index ac25f0c..031828b 100644 --- a/test/presentation/group_look_test.dart +++ b/test/presentation/group_look_test.dart @@ -108,6 +108,32 @@ void main() { await tester.pump(Duration.zero); } + testWidgets('export row and formats remain usable at 200 percent text', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final id = await seed(tester); + await open(tester, '/g/$id/settings'); + await tester.scrollUntilVisible(find.text('Export this group'), 200); + await settle(tester); + // The title itself opens the sheet, not just a trailing icon. + await tapAndSettle(tester, find.text('Export this group')); + expect(find.text('Spreadsheet (CSV)'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Full backup (JSON)')); + await settle(tester); + expect(find.text('Full backup (JSON)').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.binding.handlePopRoute(); + await settle(tester); + expect(find.text('Spreadsheet (CSV)'), findsNothing); + await unmount(tester); + }); + testWidgets('the cover is drawn from what the group spends on', ( tester, ) async { @@ -229,6 +255,8 @@ void main() { expect(tester.widget(amount).controller?.text, '12.50'); await tester.tap(find.byType(CloseButton)); await settle(tester); + expect(find.text('Discard changes?'), findsOneWidget); + await tapAndSettle(tester, find.text('Discard')); expect(find.text('Spent'), findsNWidgets(3)); expect(tester.takeException(), isNull); await unmount(tester); @@ -284,7 +312,7 @@ void main() { FocusManager.instance.primaryFocus?.context ?.findAncestorWidgetOfExactType() ?.tooltip, - 'Settle up', + 'Search expenses and payments', reason: 'Tab follows the app bar in visual reading order', ); await tester.tap( diff --git a/test/presentation/group_search_test.dart b/test/presentation/group_search_test.dart new file mode 100644 index 0000000..e1c9c1f --- /dev/null +++ b/test/presentation/group_search_test.dart @@ -0,0 +1,230 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:opensplit/application/ledger_providers.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/data/repositories/drift_entry_repository.dart'; +import 'package:opensplit/data/repositories/drift_group_repository.dart'; +import 'package:opensplit/domain/entry_draft.dart'; +import 'package:opensplit/domain/split/splitter.dart'; +import 'package:opensplit/presentation/app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 25; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(const Duration(milliseconds: 40)); + } +} + +Finder get _search => find.descendant( + of: find.byType(SearchBar), + matching: find.byType(TextField), +); + +void main() { + late AppDatabase db; + late DriftEntryRepository entries; + late String groupId; + late String ana; + late String maya; + + EntryDraft coffee({String currency = 'INR'}) => EntryDraft( + groupId: groupId, + currency: currency, + amountMinor: 10000, + description: currency == 'INR' ? 'Coffee' : 'Coffee in Tokyo', + split: EqualSplit([ana, maya]), + payerAmounts: {ana: 10000}, + ); + + setUp(() async { + db = await testDatabase(); + entries = DriftEntryRepository(db); + final groups = DriftGroupRepository(db); + final created = await groups.createGroup( + name: 'Trip', + defaultCurrency: 'INR', + creatorDisplayName: 'Ana', + creatorProfileId: testAccountId, + ); + groupId = created.group.id; + ana = created.creator.id; + maya = (await groups.addMember(groupId, displayName: 'Maya')).id; + await entries.create(coffee(), createdBy: ana); + await entries.create(coffee(currency: 'JPY'), createdBy: ana); + await entries.create( + EntryDraft.settlement( + groupId: groupId, + currency: 'INR', + amountMinor: 5000, + fromMemberId: maya, + toMemberId: ana, + notes: 'Coffee paid back', + ), + createdBy: maya, + ); + }); + tearDown(() => db.close()); + + Future mount( + WidgetTester tester, { + Size size = const Size(400, 850), + }) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + signedInApp(db: db, prefs: prefs, child: const OpenSplitApp()), + ); + await _settle(tester); + GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/g/$groupId'); + await _settle(tester); + return ProviderScope.containerOf(tester.element(find.byType(OpenSplitApp))); + } + + Future open(WidgetTester tester) async { + await tester.tap(find.byTooltip('Search expenses and payments')); + await _settle(tester); + } + + Future unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + + testWidgets('search keeps all toolbar actions and uses independent filters', ( + tester, + ) async { + final scope = await mount(tester); + expect(find.byTooltip('People'), findsOneWidget); + expect(find.byTooltip('Settle up'), findsOneWidget); + final filter = analyticsFilterControllerProvider(groupId); + final held = scope.listen(filter, (_, _) {}); + scope + .read(filter.notifier) + .update(scope.read(filter).copyWith(currency: 'JPY', query: 'other')); + await open(tester); + expect(find.textContaining('across this group'), findsOneWidget); + await tester.enterText(_search, 'cof'); + await _settle(tester); + expect( + find.widgetWithText(ListTile, 'Coffee').hitTestable(), + findsOneWidget, + ); + expect( + find.widgetWithText(ListTile, 'Coffee in Tokyo').hitTestable(), + findsOneWidget, + ); + expect( + find.widgetWithText(ListTile, 'Maya paid Ana').hitTestable(), + findsOneWidget, + ); + expect(find.textContaining('JPY 10,000'), findsOneWidget); + expect(scope.read(filter).query, 'other'); + expect(scope.read(filter).currency, 'JPY'); + + await tester.tap(find.byTooltip('Clear text')); + await _settle(tester); + expect(tester.widget(_search).controller!.text, isEmpty); + expect(find.textContaining('across this group'), findsOneWidget); + await tester.enterText(_search, 'notfound'); + await _settle(tester); + expect(find.text('No matching expenses or payments.'), findsOneWidget); + await tester.enterText(_search, 'tok'); + await tester.enterText(_search, 'paid'); + await _settle(tester); + expect( + find.widgetWithText(ListTile, 'Coffee in Tokyo').hitTestable(), + findsNothing, + ); + await tester.tap( + find.widgetWithText(ListTile, 'Maya paid Ana').hitTestable(), + ); + await _settle(tester); + expect(find.text('Edit payment'), findsOneWidget); + expect(find.byType(SearchBar), findsNothing); + await tester.tap(find.byType(BackButton).last); + await _settle(tester); + await open(tester); + expect(tester.widget(_search).controller!.text, 'paid'); + await tester.binding.handlePopRoute(); + await _settle(tester); + expect(find.byType(SearchBar), findsNothing); + held.close(); + expect(tester.takeException(), isNull); + await unmount(tester); + }); + + testWidgets('results react while open and expenses open their editor', ( + tester, + ) async { + await mount(tester); + await open(tester); + await tester.enterText(_search, 'coffee'); + await _settle(tester); + final added = (await tester.runAsync( + () => entries.create(coffee(currency: 'USD'), createdBy: ana), + ))!; + await _settle(tester); + expect(find.textContaining('USD 100.00'), findsOneWidget); + await tester.runAsync(() => entries.delete(added.id, actorId: ana)); + await _settle(tester); + expect(find.textContaining('USD 100.00'), findsNothing); + await tester.tap(find.widgetWithText(ListTile, 'Coffee').hitTestable()); + await _settle(tester); + expect(find.text('Edit expense'), findsOneWidget); + expect(tester.takeException(), isNull); + await unmount(tester); + }); + + testWidgets('losing group access dismisses its open search', (tester) async { + await mount(tester); + await open(tester); + await tester.enterText(_search, 'coffee'); + await _settle(tester); + await tester.runAsync( + () => (db.update(db.members)..where((member) => member.id.equals(ana))) + .write(MembersCompanion(leftAt: Value(DateTime.now()))), + ); + await _settle(tester); + expect(find.text('Group unavailable'), findsOneWidget); + expect(find.byType(SearchBar), findsNothing); + expect(find.widgetWithText(ListTile, 'Coffee').hitTestable(), findsNothing); + expect(tester.takeException(), isNull); + await unmount(tester); + }); + + testWidgets( + 'narrow large text search remains usable with the keyboard', + (tester) async { + await mount(tester, size: const Size(320, 700)); + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await open(tester); + tester.view.viewInsets = const FakeViewPadding(bottom: 280); + await tester.enterText(_search, 'coffee'); + await _settle(tester); + expect(find.byType(SearchBar), findsOneWidget); + await tester.tap(find.byTooltip('Clear text')); + await _settle(tester); + await tester.tap(find.byType(BackButton).last); + await _settle(tester); + expect(find.byType(SearchBar), findsNothing); + expect(find.byTooltip('People'), findsOneWidget); + expect(find.byTooltip('Settle up'), findsOneWidget); + expect(tester.takeException(), isNull); + await unmount(tester); + }, + variant: TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.macOS, + }), + ); +} diff --git a/test/presentation/group_surface_test.dart b/test/presentation/group_surface_test.dart new file mode 100644 index 0000000..9407555 --- /dev/null +++ b/test/presentation/group_surface_test.dart @@ -0,0 +1,116 @@ +import 'dart:math' as math; + +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:opensplit/application/ledger_providers.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/presentation/theme.dart'; +import 'package:opensplit/presentation/widgets/group_cover.dart'; +import 'package:opensplit_api/opensplit_api.dart' show AvatarKind, CoverKind; + +import '../fixtures/android_wallpaper.dart'; + +double _contrast(Color a, Color b) { + final first = a.computeLuminance(); + final second = b.computeLuminance(); + return (math.max(first, second) + .05) / (math.min(first, second) + .05); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + tearDown( + () => messenger.setMockMethodCallHandler(DynamicColorPlugin.channel, null), + ); + + for (final wallpaper in [false, true]) { + for (final brightness in Brightness.values) { + testWidgets( + 'group text over artwork: $brightness / wallpaper $wallpaper', + (tester) async { + messenger.setMockMethodCallHandler( + DynamicColorPlugin.channel, + (_) async => tealAndroidWallpaper(), + ); + final palette = await DynamicColorPlugin.getCorePalette(); + final theme = buildTheme( + brightness, + wallpaper ? palette!.toColorScheme(brightness: brightness) : null, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + categoriesProvider.overrideWith((ref) => Stream.value([])), + ], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: SizedBox( + height: 160, + child: GroupCover(ledger: _ledger, patternFade: 1), + ), + ), + ), + ), + ); + final cover = find.byType(GroupCover); + final surface = tester + .widget( + find.descendant(of: cover, matching: find.byType(ColoredBox)), + ) + .color; + final opacity = tester + .widget( + find.descendant(of: cover, matching: find.byType(Opacity)), + ) + .opacity; + final colors = theme.extension()!; + // Black/white bound every motif colour, including multicolour emoji. + // Text can wrap across any part of the artwork at large font sizes. + for (final motif in [Colors.black, Colors.white]) { + final background = Color.alphaBlend( + motif.withValues(alpha: opacity), + surface, + ); + for (final text in [ + theme.colorScheme.onSurface, + theme.colorScheme.onSurfaceVariant, + colors.credit, + colors.debit, + ]) { + expect(_contrast(text, background), greaterThanOrEqualTo(4.5)); + } + } + expect( + _contrast(surface, theme.colorScheme.surface), + greaterThan(1.1), + ); + }, + ); + } + } +} + +final _ledger = GroupLedger( + group: Group( + id: 'group', + name: 'Trip', + defaultCurrency: 'INR', + createdAt: DateTime.utc(2026), + avatarKind: AvatarKind.initials, + coverKind: CoverKind.generated, + isDirect: false, + simplifyDebts: true, + ), + members: const [], + pastMembers: const [], + entries: const [], + balances: const [], + transfers: const [], + me: null, + profiles: const {}, + brokenEntries: const [], +); diff --git a/test/presentation/insights_search_test.dart b/test/presentation/insights_search_test.dart new file mode 100644 index 0000000..587a22f --- /dev/null +++ b/test/presentation/insights_search_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:opensplit/application/ledger_providers.dart'; +import 'package:opensplit/data/local/database.dart'; +import 'package:opensplit/data/repositories/drift_entry_repository.dart'; +import 'package:opensplit/data/repositories/drift_group_repository.dart'; +import 'package:opensplit/domain/entry_draft.dart'; +import 'package:opensplit/domain/split/splitter.dart'; +import 'package:opensplit/presentation/app.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../harness.dart'; + +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 25; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(const Duration(milliseconds: 40)); + } +} + +Finder get _search => find.descendant( + of: find.byType(SearchBar), + matching: find.byType(TextField), +); + +TextEditingValue _editingValue(WidgetTester tester) => tester + .widget( + find.descendant(of: _search, matching: find.byType(EditableText)), + ) + .controller + .value; + +void main() { + late AppDatabase db; + late String groupId; + late String maya; + + setUp(() async { + db = await testDatabase(); + final groups = DriftGroupRepository(db); + final created = await groups.createGroup( + name: 'Trip', + defaultCurrency: 'INR', + creatorDisplayName: 'Ana', + creatorProfileId: testAccountId, + ); + groupId = created.group.id; + maya = (await groups.addMember(groupId, displayName: 'Maya')).id; + for (final (description, currency, amount) in [ + ('Coffee', 'INR', 10000), + ('Dinner', 'INR', 30000), + ('Coffee in Tokyo', 'JPY', 500), + ]) { + await DriftEntryRepository(db).create( + EntryDraft( + groupId: groupId, + currency: currency, + amountMinor: amount, + description: description, + split: EqualSplit([created.creator.id, maya]), + payerAmounts: {created.creator.id: amount}, + ), + createdBy: created.creator.id, + ); + } + }); + tearDown(() => db.close()); + + Future mount(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + tester.view.physicalSize = const Size(400, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + signedInApp(db: db, prefs: prefs, child: const OpenSplitApp()), + ); + await _settle(tester); + return ProviderScope.containerOf(tester.element(find.byType(OpenSplitApp))); + } + + Future open(WidgetTester tester) async { + GoRouter.of( + tester.element(find.byType(Scaffold).first), + ).go('/g/$groupId/insights'); + await _settle(tester); + } + + Future unmount(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(Duration.zero); + } + + testWidgets('Clear search clears text and results, retaining other filters', ( + tester, + ) async { + final scope = await mount(tester); + await open(tester); + await tester.tap(find.widgetWithText(FilterChip, 'Maya')); + await tester.enterText(_search, 'cof'); + await _settle(tester); + expect(find.text('1 expense'), findsOneWidget); + expect(find.text('1 matching entry'), findsOneWidget); + + await tester.tap(find.byTooltip('Clear search')); + await _settle(tester); + expect(_editingValue(tester).text, isEmpty); + final filter = scope.read(analyticsFilterControllerProvider(groupId)); + expect(filter.query, isEmpty); + expect(filter.memberId, maya); + expect(filter.currency, 'INR'); + expect(find.text('2 expenses'), findsOneWidget); + expect(find.byTooltip('Clear search'), findsNothing); + + await tester.enterText(_search, 'din'); + await _settle(tester); + expect(find.text('1 expense'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'Dinner'), findsOneWidget); + expect(tester.takeException(), isNull); + await unmount(tester); + }); + + testWidgets('Clear filters clears the search text as well as its query', ( + tester, + ) async { + final scope = await mount(tester); + await open(tester); + await tester.tap(find.widgetWithText(FilterChip, 'Maya')); + await tester.enterText(_search, 'cof'); + await _settle(tester); + await tester.tap(find.widgetWithText(TextButton, 'Clear filters')); + await _settle(tester); + expect(_editingValue(tester).text, isEmpty); + final filter = scope.read(analyticsFilterControllerProvider(groupId)); + expect(filter.query, isEmpty); + expect(filter.memberId, isNull); + expect(find.text('2 expenses'), findsOneWidget); + expect(find.textContaining('matching entr'), findsNothing); + expect(tester.takeException(), isNull); + await unmount(tester); + }); + + testWidgets('restored queries synchronize without disturbing typing', ( + tester, + ) async { + final scope = await mount(tester); + final provider = analyticsFilterControllerProvider(groupId); + final subscription = scope.listen(provider, (_, _) {}); + final controller = scope.read(provider.notifier); + controller.update(scope.read(provider).copyWith(query: 'cof')); + await open(tester); + expect(_editingValue(tester).text, 'cof'); + expect(find.text('1 expense'), findsOneWidget); + + await tester.showKeyboard(_search); + const typed = TextEditingValue( + text: 'coffee', + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 0, end: 6), + ); + tester.testTextInput.updateEditingValue(typed); + await _settle(tester); + expect(_editingValue(tester), typed); + controller.update(scope.read(provider).copyWith(memberId: maya)); + await _settle(tester); + expect(_editingValue(tester), typed); + + controller.update(scope.read(provider).copyWith(query: 'din')); + await _settle(tester); + expect(_editingValue(tester).text, 'din'); + expect(find.widgetWithText(ListTile, 'Dinner'), findsOneWidget); + subscription.close(); + expect(tester.takeException(), isNull); + await unmount(tester); + }); +} diff --git a/test/presentation/navigation_test.dart b/test/presentation/navigation_test.dart index 0c22c62..e59aec2 100644 --- a/test/presentation/navigation_test.dart +++ b/test/presentation/navigation_test.dart @@ -14,6 +14,7 @@ import 'package:opensplit/presentation/screens/group_list_screen.dart'; import 'package:opensplit/presentation/screens/settings_screen.dart'; import 'package:opensplit/presentation/widgets/avatar_view.dart'; import 'package:opensplit/presentation/widgets/group_avatar_hero.dart'; +import 'package:opensplit/presentation/widgets/segmented_list.dart'; import 'package:opensplit/presentation/widgets/group_cover.dart'; import 'package:opensplit/presentation/widgets/shared_element.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -238,11 +239,11 @@ void main() { of: find.text('Flat 4B'), matching: find.byType(ListTile), ); - expect(find.ancestor(of: tile, matching: find.byType(Card)), findsOne); + expect(find.ancestor(of: tile, matching: find.byType(Segment)), findsOne); expect( tester.getRect(find.byType(GroupCover)), tester.getRect(tile), - reason: 'the quiet artwork fills the card behind its identity', + reason: 'the quiet artwork fills the segment behind its identity', ); await tester.tap(find.text('Flat 4B')); diff --git a/test/presentation/settle_up_test.dart b/test/presentation/settle_up_test.dart index 9cb6070..a1e4e60 100644 --- a/test/presentation/settle_up_test.dart +++ b/test/presentation/settle_up_test.dart @@ -126,6 +126,49 @@ void main() { tearDown(() => db.close()); + testWidgets('payment notes are protected by Back and system back', ( + tester, + ) async { + await _open(tester, db, '/g/g1/settle'); + final notes = find.ancestor( + of: find.text('Payment note (optional)'), + matching: find.byType(TextField), + ); + await tester.enterText(notes, 'Cash after dinner'); + await _beats(tester); + await tester.tap(find.byType(BackButton).last); + await _beats(tester); + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.text('Keep editing')); + await _beats(tester); + expect( + tester.widget(notes).controller!.text, + 'Cash after dinner', + ); + await tester.binding.handlePopRoute(); + await _beats(tester); + await tester.tap(find.text('Discard')); + await _beats(tester); + expect(find.text('Settle up'), findsNothing); + expect(await db.select(db.entries).get(), isEmpty); + await _unmount(tester); + }); + + testWidgets('an untouched suggested payment leaves without a prompt', ( + tester, + ) async { + await _open( + tester, + db, + '/g/g1/settle?from=m-ravi&to=m-priya&amount=1200¤cy=INR', + ); + await tester.tap(find.byType(BackButton).last); + await _beats(tester); + expect(find.text('Discard changes?'), findsNothing); + expect(find.text('Settle up'), findsNothing); + await _unmount(tester); + }); + testWidgets('the entry URL opens a payment editor and saves a payment', ( tester, ) async { diff --git a/test/presentation/sync_refresh_test.dart b/test/presentation/sync_refresh_test.dart index 98d583d..b7eb58b 100644 --- a/test/presentation/sync_refresh_test.dart +++ b/test/presentation/sync_refresh_test.dart @@ -353,24 +353,49 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('a narrow group keeps its name and its two main actions', ( + testWidgets('a narrow offline group keeps all three main actions', ( tester, ) async { tester.view.physicalSize = const Size(320, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await _mount(tester, _TestSync(), GroupDetailScreen(groupId: _group.id)); + final sync = _TestSync( + initial: SyncStatus( + hasCompletedFullSync: true, + lastReport: SyncReport( + pushed: 0, + pulled: 0, + failed: 0, + error: DioException( + requestOptions: RequestOptions(path: '/sync'), + type: DioExceptionType.connectionError, + ), + ), + ), + ); + await _mount(tester, sync, GroupDetailScreen(groupId: _group.id)); await tester.pumpAndSettle(); - if (kIsWeb) expect(find.byTooltip('Refresh'), findsOneWidget); - expect(find.byTooltip('Settle up'), findsOneWidget); - expect(find.byTooltip('People'), findsOneWidget); + expect(find.byIcon(Icons.cloud_off_outlined), findsOneWidget); + expect( + find.byTooltip('Search expenses and payments').hitTestable(), + findsOneWidget, + ); + expect(find.byTooltip('Settle up').hitTestable(), findsOneWidget); + expect(find.byTooltip('People').hitTestable(), findsOneWidget); await tester.tap(find.byTooltip('More')); await tester.pumpAndSettle(); for (final label in ['Insights', 'Activity', 'Group settings']) { expect(find.text(label), findsOneWidget); } + if (kIsWeb) { + await tester.tap(find.text('Refresh')); + await tester.pump(); + expect(sync.requests, [_group.id]); + sync.finish(); + await tester.pumpAndSettle(); + } expect(tester.takeException(), isNull); }); } diff --git a/tool/screenshots_test.dart b/tool/screenshots_test.dart index 73da65a..3fb22d9 100644 --- a/tool/screenshots_test.dart +++ b/tool/screenshots_test.dart @@ -19,7 +19,10 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/foundation.dart' + show debugDefaultTargetPlatformOverride; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,6 +37,7 @@ import 'package:opensplit/data/repositories/drift_group_repository.dart'; import 'package:opensplit/domain/entry_draft.dart'; import 'package:opensplit/domain/split/splitter.dart'; import 'package:opensplit/presentation/app.dart'; +import 'package:opensplit/presentation/dynamic_colors.dart'; import 'package:opensplit/presentation/screens/welcome_screen.dart'; import 'package:opensplit/presentation/theme.dart'; import 'package:opensplit/presentation/theme_mode.dart'; @@ -44,6 +48,7 @@ import 'package:uuid/rng.dart'; import 'package:opensplit_api/opensplit_api.dart' show SplitKind; import '../test/harness.dart'; +import '../test/fixtures/android_wallpaper.dart'; /// Where the images go: served by the site and packaged by the Play release. const _output = String.fromEnvironment( @@ -88,7 +93,19 @@ void main() { await tester.pumpWidget( RepaintBoundary( key: frame, - child: signedInApp(db: db, prefs: prefs, child: const OpenSplitApp()), + child: signedInApp( + db: db, + prefs: prefs, + overrides: [ + if (const bool.fromEnvironment('REVIEW_SINGLE_GROUP')) + groupListingsProvider.overrideWith( + (ref) => DriftGroupRepository( + db, + ).watchListings().map((groups) => groups.take(1).toList()), + ), + ], + child: const OpenSplitApp(), + ), ), ); final router = ProviderScope.containerOf( @@ -96,6 +113,129 @@ void main() { ).read(routerProvider); Future shoot(String name) => _capture(tester, frame, name); + if (const bool.fromEnvironment('RENDER_SEARCH_REVIEW')) { + final container = ProviderScope.containerOf( + tester.element(find.byType(OpenSplitApp)), + ); + addTearDown(() => debugDefaultTargetPlatformOverride = null); + for (final (label, device) in [ + ('phone', _reviewPhone), + ('desktop', _desktop), + ]) { + debugDefaultTargetPlatformOverride = label == 'phone' + ? TargetPlatform.android + : TargetPlatform.macOS; + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + await container.read(themeModeProvider.notifier).set(mode); + _resize(tester, device); + router.go('/g/$trip'); + await shoot('search-toolbar-$label-${mode.name}'); + await tester.tap(find.byTooltip('Search expenses and payments')); + await _settle(tester); + await tester.enterText( + find.descendant( + of: find.byType(SearchBar), + matching: find.byType(TextField), + ), + 'dinner', + ); + await shoot('search-results-$label-${mode.name}'); + await tester.tap(find.byType(BackButton).last); + await _settle(tester); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + await _settle(tester); + await tester.runAsync(db.close); + debugDefaultTargetPlatformOverride = null; + debugDisableShadows = true; + return; + } + + if (const bool.fromEnvironment('RENDER_SURFACE_REVIEW')) { + _resize(tester, _reviewPhone); + final container = ProviderScope.containerOf( + tester.element(find.byType(OpenSplitApp)), + ); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + DynamicColorPlugin.channel, + (call) async => + call.method == 'getCorePalette' ? tealAndroidWallpaper() : null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler( + DynamicColorPlugin.channel, + null, + ), + ); + container.invalidate(wallpaperSchemesProvider); + for (final wallpaper in [false, true]) { + await container + .read(wallpaperColorsProvider.notifier) + .set(enabled: wallpaper); + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + await container.read(themeModeProvider.notifier).set(mode); + final palette = wallpaper ? 'wallpaper' : 'brand'; + final suffix = '$palette-${mode.name}'; + router.go('/'); + final count = const bool.fromEnvironment('REVIEW_SINGLE_GROUP') + ? 'one-' + : ''; + await shoot('groups-$count$suffix'); + if (const bool.fromEnvironment('RENDER_GROUPS_ONLY')) continue; + for (final (name, path) in [ + ('expenses', '/g/$trip'), + ('members', '/g/$trip/members'), + ('group-settings', '/g/$trip/settings'), + ('group-picture', '/g/$trip/picture'), + ('settle', '/g/$trip/settle'), + ('insights', '/g/$trip/insights'), + ('activity', '/g/$trip/activity'), + ('settings', '/settings'), + ('account', '/account'), + ('profile', '/account/edit'), + ('about', '/about'), + ]) { + router.go(path); + await shoot('$name-$suffix'); + } + router.go('/g/$trip'); + await _settle(tester); + await tester.tap(find.text('Balances')); + await shoot('balances-$suffix'); + router.go('/'); + await _settle(tester); + await tester.tap( + find.widgetWithText(FloatingActionButton, 'New group'), + ); + await shoot('new-group-$suffix'); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); + await _settle(tester); + router.go('/g/$trip/add'); + await _settle(tester); + await _fillExpense(tester); + await shoot('add-expense-$suffix'); + await tester.tap(find.byType(CloseButton)); + await shoot('discard-$suffix'); + await tester.tap(find.text('Keep editing')); + await _settle(tester); + router.go('/g/$trip/settings'); + await _settle(tester); + await tester.scrollUntilVisible(find.text('Export this group'), 200); + await tester.tap(find.text('Export this group')); + await shoot('export-$suffix'); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); + await _settle(tester); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + await _settle(tester); + await tester.runAsync(db.close); + debugDisableShadows = true; + return; + } + await shoot('screenshot-1-groups'); router.go('/g/$trip'); @@ -240,6 +380,7 @@ Future _capture( String output = _output, }) async { await _settle(tester); + expect(tester.takeException(), isNull, reason: 'Rendering $name'); final boundary = frame.currentContext!.findRenderObject()!; await tester.runAsync(() async { final image = await (boundary as RenderRepaintBoundary).toImage( diff --git a/tool/store_artifacts.dart b/tool/store_artifacts.dart index 56030ac..3e61d5c 100644 --- a/tool/store_artifacts.dart +++ b/tool/store_artifacts.dart @@ -96,6 +96,7 @@ Future> _inputs() async { 'tool/store_graphics.py', 'tool/store_artifacts.dart', 'test/harness.dart', + 'test/fixtures/android_wallpaper.dart', 'test/data/server_reference_data.dart', '.github/workflows/ci.yml', for (final directory in ['lib', 'assets', 'packages/opensplit_api/lib'])