From 09e16408b3e1b5baee39c5cd3475ef259e3f9e4c Mon Sep 17 00:00:00 2001 From: Seenu K Date: Tue, 13 Oct 2026 00:16:36 +0530 Subject: [PATCH] Refine Material group cards, form fields, and app copy --- CHANGELOG.md | 3 + lib/presentation/screens/about_screen.dart | 23 +-- lib/presentation/screens/activity_screen.dart | 4 +- .../screens/edit_profile_screen.dart | 10 +- .../screens/entry_editor_screen.dart | 3 +- .../screens/group_list_screen.dart | 118 ++++++++------ .../screens/group_settings_screen.dart | 24 +-- lib/presentation/screens/join_screen.dart | 17 +-- .../screens/save_account_screen.dart | 6 +- lib/presentation/screens/settings_screen.dart | 9 +- lib/presentation/screens/welcome_screen.dart | 4 +- lib/presentation/theme.dart | 24 ++- lib/presentation/widgets/amount_card.dart | 51 ++++--- lib/presentation/widgets/balances_panel.dart | 26 ++-- lib/presentation/widgets/brand_mark.dart | 3 + .../widgets/create_group_sheet.dart | 99 ++++++------ lib/presentation/widgets/empty_state.dart | 2 +- lib/presentation/widgets/group_cover.dart | 59 +++---- lib/presentation/widgets/group_standing.dart | 10 +- .../widgets/identity_choices.dart | 5 +- .../widgets/link_account_prompt.dart | 10 +- .../widgets/notification_invitation.dart | 7 +- .../widgets/save_account_form.dart | 15 +- lib/presentation/widgets/shared_element.dart | 10 +- site/404.html | 2 +- site/delete-account.html | 2 +- site/index.html | 2 +- site/privacy.html | 2 +- site/terms.html | 2 +- test/presentation/amount_card_test.dart | 17 +++ test/presentation/create_group_test.dart | 36 ++++- test/presentation/email_code_test.dart | 2 + test/presentation/group_look_test.dart | 15 ++ test/presentation/navigation_test.dart | 144 +++++++++--------- test/site_meta_test.dart | 15 ++ tool/screenshots_test.dart | 67 +++++++- 36 files changed, 497 insertions(+), 351 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbfb934..d7ee40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ What changed in each version of the app, newest first. The format is [cider](https://pub.dev/packages/cider): see "Versions" in docs/runbook.md. ## [Unreleased] +### Changed +- Groups have larger names and avatars with quieter cover art. Forms use consistent filled fields, and app wording is shorter. + ### Fixed - Improved browser database transaction reliability. diff --git a/lib/presentation/screens/about_screen.dart b/lib/presentation/screens/about_screen.dart index 0fe0da9..74ebb35 100644 --- a/lib/presentation/screens/about_screen.dart +++ b/lib/presentation/screens/about_screen.dart @@ -36,10 +36,7 @@ class AboutScreen extends StatelessWidget { children: [ const Padding( padding: EdgeInsets.symmetric(vertical: 24), - child: BrandHeader( - title: 'OpenSplit', - subtitle: 'Shared expenses, without the extraction.', - ), + child: BrandHeader(title: 'OpenSplit', subtitle: brandTagline), ), const _Version(), const Divider(height: 40), @@ -47,21 +44,19 @@ class AboutScreen extends StatelessWidget { _Link( icon: Icons.code, title: 'Source code', - subtitle: 'Every line of this app, and the server behind it.', + subtitle: 'App and server.', url: repositoryUrl, ), _Link( icon: Icons.bug_report_outlined, title: 'Report an issue', - subtitle: 'Bugs, and things that work but should not.', + subtitle: 'Bugs and suggestions.', url: issuesUrl, ), _Link( icon: Icons.balance_outlined, title: 'Licence — AGPL-3.0', - subtitle: - 'Anyone can use, change and share the code, and what is ' - 'built from it stays open source too.', + subtitle: 'Use, modify and share. Keep changes open source.', url: licenseUrl, ), @@ -70,10 +65,7 @@ class AboutScreen extends StatelessWidget { contentPadding: EdgeInsets.zero, leading: const Icon(Icons.inventory_outlined), title: const Text('Open-source licences'), - subtitle: const Text( - 'The packages and typefaces this app is built on, and their ' - 'terms.', - ), + subtitle: const Text('Packages and fonts.'), trailing: const Icon(Icons.chevron_right), onTap: () => showLicensePage( context: context, @@ -84,10 +76,7 @@ class AboutScreen extends StatelessWidget { const Divider(height: 40), Text( - 'Free for everyone, open source, with no ads, no analytics SDK ' - 'and no venture funding. Those are commitments rather than ' - 'features — they are written down in the repository so they can ' - 'be held against us.', + 'Open source. No ads, analytics SDKs or venture funding.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/presentation/screens/activity_screen.dart b/lib/presentation/screens/activity_screen.dart index ee916e5..1236624 100644 --- a/lib/presentation/screens/activity_screen.dart +++ b/lib/presentation/screens/activity_screen.dart @@ -200,8 +200,6 @@ class _Empty extends StatelessWidget { Widget build(BuildContext context) => const EmptyState( icon: Icons.history, title: 'Nothing has happened here yet', - message: - 'Every expense added, edited or deleted in this group shows up ' - 'here, with who did it.', + message: 'Expense changes and who made them appear here.', ); } diff --git a/lib/presentation/screens/edit_profile_screen.dart b/lib/presentation/screens/edit_profile_screen.dart index 489e605..53a3ea7 100644 --- a/lib/presentation/screens/edit_profile_screen.dart +++ b/lib/presentation/screens/edit_profile_screen.dart @@ -168,10 +168,7 @@ class _EditProfileScreenState extends ConsumerState { autofillHints: const [AutofillHints.name], decoration: const InputDecoration( labelText: 'Name', - helperText: - 'What everybody in your groups sees. Changing ' - 'it here changes it everywhere, including for ' - 'them.', + helperText: 'Your name in all groups.', helperMaxLines: 3, ), // A blank name would look saved while the member @@ -193,10 +190,7 @@ class _EditProfileScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'UPI ID (optional)', hintText: 'you@bank', - helperText: - 'Lets people in your groups open their UPI app ' - 'to pay you. OpenSplit never handles the ' - 'money.', + helperText: 'Receive payments in your UPI app.', helperMaxLines: 3, ), validator: (value) { diff --git a/lib/presentation/screens/entry_editor_screen.dart b/lib/presentation/screens/entry_editor_screen.dart index 0a57f85..41429c7 100644 --- a/lib/presentation/screens/entry_editor_screen.dart +++ b/lib/presentation/screens/entry_editor_screen.dart @@ -192,8 +192,7 @@ class _Editor extends ConsumerWidget { Padding( padding: const EdgeInsets.only(top: 4), child: Text( - 'Stored in ${currency.code}. Balances in a group are ' - 'always kept per currency, never converted.', + '${currency.code} balances stay separate.', style: textTheme.bodySmall, ), ), diff --git a/lib/presentation/screens/group_list_screen.dart b/lib/presentation/screens/group_list_screen.dart index 2b4b0ee..a422734 100644 --- a/lib/presentation/screens/group_list_screen.dart +++ b/lib/presentation/screens/group_list_screen.dart @@ -120,16 +120,11 @@ abstract final class _GroupList { // be folding balances. sliver: SliverList.builder( itemCount: groups.length, - itemBuilder: (context, index) => Segment( - index: index, - count: groups.length, - child: _GroupTile( - group: groups[index], - borderRadius: Segment.radiusAt( - index: index, - count: groups.length, - ), + itemBuilder: (context, index) => Padding( + padding: EdgeInsets.only( + bottom: index == groups.length - 1 ? 0 : 12, ), + child: _GroupTile(group: groups[index]), ), ), ), @@ -163,55 +158,82 @@ class _ArchivedRow extends StatelessWidget { } class _GroupTile extends ConsumerWidget { - const _GroupTile({required this.group, required this.borderRadius}); + const _GroupTile({required this.group}); final Group group; - final BorderRadius borderRadius; @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)); - // A real ListTile, for its touch target, density and large-font growth. - return Stack( - children: [ - if (ledger != null) - Positioned.fill( - child: GroupCoverHero( - ledger: ledger, - borderRadius: borderRadius, - patternFade: 1, - ), - ), - Material( - type: MaterialType.transparency, - child: ListTile( - onTap: () => context.push('/g/${group.id}'), - contentPadding: const EdgeInsetsDirectional.fromSTEB( - 16, - 12, - 24, - 12, - ), - leading: CircleAvatar( - radius: 24, - backgroundColor: Theme.of(context).colorScheme.surface, - child: GroupAvatarHero(group: group, radius: 22), + // 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, + ), ), - title: SharedText( - tag: ('group-name', group.id), - text: group.name, - style: Theme.of(context).textTheme.titleMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurface, + 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, + ), + ), + const SizedBox(height: 4), + ], + GroupStanding( + ledger: ledger, + currencies: currencies, + style: theme.textTheme.bodyLarge, + overflow: TextOverflow.clip, + ), + ], + ), + ), ), - subtitle: ledger == null - ? const LaunchPlaceholder(child: SizedBox(height: 20)) - : GroupStanding(ledger: ledger, currencies: currencies), ), - ), - ], + ], + ), ); } } @@ -223,9 +245,7 @@ class _EmptyState extends StatelessWidget { Widget build(BuildContext context) => const EmptyState( icon: Icons.groups_outlined, title: 'No groups yet', - message: - 'Make one for a trip, a flat, or a single dinner. ' - 'You can add people who do not have the app.', + message: 'Create a group for a trip, flat or dinner.', ); } diff --git a/lib/presentation/screens/group_settings_screen.dart b/lib/presentation/screens/group_settings_screen.dart index 61f3019..e5242a0 100644 --- a/lib/presentation/screens/group_settings_screen.dart +++ b/lib/presentation/screens/group_settings_screen.dart @@ -224,10 +224,8 @@ class _GroupSettingsScreenState extends ConsumerState { ), title: const Text('Suggest the fewest payments'), subtitle: const Text( - 'On: as few payments as settle the group, which can mean ' - 'paying someone you never shared an expense with. Off: ' - 'each person settles with each person they owe. Either ' - 'way, everyone ends up square.', + 'Fewer payments, same balances. You may pay someone ' + 'you did not directly owe.', ), ), @@ -238,8 +236,7 @@ class _GroupSettingsScreenState extends ConsumerState { : (value) => _setArchived(ledger, archived: value), title: const Text('Archive'), subtitle: const Text( - 'Hides it from your list. Nothing is deleted, everyone stays ' - 'in it, and un-archiving brings it straight back.', + 'Hide this group without deleting its expenses.', ), ), ], @@ -251,8 +248,7 @@ class _GroupSettingsScreenState extends ConsumerState { leading: const Icon(Icons.ios_share), title: const Text('Export this group'), subtitle: const Text( - 'A spreadsheet to read, or a full backup that keeps every ' - 'split, rate and change.', + 'Download a spreadsheet or full backup.', ), isThreeLine: true, trailing: ExportButton(groupId: widget.groupId), @@ -263,8 +259,7 @@ class _GroupSettingsScreenState extends ConsumerState { if (me == null) Text( - 'You are not a member of this group, so there is nothing here ' - 'to leave.', + 'You are not a member of this group.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant, ), @@ -278,19 +273,14 @@ class _GroupSettingsScreenState extends ConsumerState { 'Leave group', style: TextStyle(color: scheme.error), ), - subtitle: const Text( - 'Your past expenses stay in the group. You stop appearing in ' - 'new ones.', - ), + subtitle: const Text('Past expenses stay in the group.'), onTap: _busy ? null : () => _leave(ledger, me), ), ], ), const SizedBox(height: 8), Text( - 'A group cannot be deleted once it has expenses in it, by ' - 'design — somebody else\'s record of who paid for what is not ' - 'yours to remove. Archive it instead.', + 'Groups with expenses cannot be deleted. Archive them instead.', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), diff --git a/lib/presentation/screens/join_screen.dart b/lib/presentation/screens/join_screen.dart index adf8060..2b2a3d2 100644 --- a/lib/presentation/screens/join_screen.dart +++ b/lib/presentation/screens/join_screen.dart @@ -218,10 +218,7 @@ class _JoinScreenState extends ConsumerState { children: [ BrandHeader( title: 'Rejoin ${preview.groupName}', - subtitle: - 'You were in this group before. Rejoining brings back your place ' - 'as it was, with everything you paid and owed, and you get ' - 'updates again.', + subtitle: 'Restore your membership, expenses and balances.', ), const SizedBox(height: 32), ..._errorIfAny(theme), @@ -345,10 +342,6 @@ class _JoinScreenState extends ConsumerState { // Not joined yet, deliberately. if (mounted) setState(() {}); }, - guestNote: - 'You will join straight away and the group will synchronize. ' - 'This device is the only way back into the guest account ' - 'until you add an email address or Google account.', ) else _ChoosePlace( @@ -529,13 +522,7 @@ class _Claim extends ConsumerWidget { // Nobody yet. The three ways to become somebody, then the claim happens // against whichever one they picked. if (account == null) { - return IdentityChoices( - onSignedIn: onJoin, - guestNote: - 'You will join straight away and the group will synchronize. This ' - 'device is the only way back into the guest account until you add ' - 'an email address or Google account.', - ); + return IdentityChoices(onSignedIn: onJoin); } final me = ref.watch(myProfileProvider).value; diff --git a/lib/presentation/screens/save_account_screen.dart b/lib/presentation/screens/save_account_screen.dart index a18510d..94d85a8 100644 --- a/lib/presentation/screens/save_account_screen.dart +++ b/lib/presentation/screens/save_account_screen.dart @@ -53,10 +53,8 @@ class _Unsaved extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Your groups are synchronized, but this device is the only way back ' - 'into this guest account, and only while you open OpenSplit at ' - 'least once a year. Adding an email address lets you recover it and ' - 'use OpenSplit on more than one device.', + 'Guest access depends on this device and yearly use. Add an email ' + 'to recover your account and sign in on other devices.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 6baf061..134052b 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -281,7 +281,7 @@ class _WallpaperSetting extends ConsumerWidget { ref.read(wallpaperColorsProvider.notifier).set(enabled: wanted), secondary: const Icon(Icons.palette_outlined), title: const Text('Match my wallpaper'), - subtitle: const Text('Use your device palette throughout OpenSplit.'), + subtitle: const Text('Use your device’s colours.'), ), const SizedBox(height: 8), // The palette itself, so that "it is on and nothing changed" is @@ -373,12 +373,7 @@ class _NotificationSettingState extends ConsumerState<_NotificationSetting> { value: enabled, onChanged: _busy ? null : _toggle, title: const Text('Tell me about group activity'), - subtitle: const Text( - 'A quiet notification when someone adds an expense or settles up. ' - 'The message is put together on your device — the server only ' - 'sends an id, never an amount.', - ), - isThreeLine: true, + subtitle: const Text('New expenses and payments in your groups.'), ), ], ); diff --git a/lib/presentation/screens/welcome_screen.dart b/lib/presentation/screens/welcome_screen.dart index f3399ea..fe7c054 100644 --- a/lib/presentation/screens/welcome_screen.dart +++ b/lib/presentation/screens/welcome_screen.dart @@ -25,9 +25,7 @@ class WelcomeScreen extends ConsumerWidget { children: [ const BrandHeader( title: 'OpenSplit', - subtitle: - 'Split expenses with the people you actually spend ' - 'money with.', + subtitle: brandTagline, ), const SizedBox(height: 40), diff --git a/lib/presentation/theme.dart b/lib/presentation/theme.dart index bfcd5c2..00cac7a 100644 --- a/lib/presentation/theme.dart +++ b/lib/presentation/theme.dart @@ -126,9 +126,25 @@ ThemeData buildTheme(Brightness brightness, [ColorScheme? dynamicScheme]) { // DropdownMenu has its own decoration defaults and does not inherit the // app's field theme. Supply the same policy to both component families. - const fields = InputDecorationThemeData( + const fieldBorder = UnderlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide.none, + ); + final fields = InputDecorationThemeData( filled: true, - border: UnderlineInputBorder(), + fillColor: scheme.surfaceContainerHigh, + border: fieldBorder, + enabledBorder: fieldBorder, + disabledBorder: fieldBorder, + focusedBorder: fieldBorder.copyWith( + borderSide: BorderSide(color: scheme.primary, width: 2), + ), + errorBorder: fieldBorder.copyWith( + borderSide: BorderSide(color: scheme.error), + ), + focusedErrorBorder: fieldBorder.copyWith( + borderSide: BorderSide(color: scheme.error, width: 2), + ), helperMaxLines: 3, errorMaxLines: 3, ); @@ -151,9 +167,7 @@ ThemeData buildTheme(Brightness brightness, [ColorScheme? dynamicScheme]) { variant: StyleVariant.material3Expressive, ), inputDecorationTheme: fields, - dropdownMenuTheme: const DropdownMenuThemeData( - inputDecorationTheme: fields, - ), + dropdownMenuTheme: DropdownMenuThemeData(inputDecorationTheme: fields), ); // Google Sans Flex, resolved from the bundle rather than the network — see the diff --git a/lib/presentation/widgets/amount_card.dart b/lib/presentation/widgets/amount_card.dart index 72b688b..c049ee0 100644 --- a/lib/presentation/widgets/amount_card.dart +++ b/lib/presentation/widgets/amount_card.dart @@ -41,28 +41,37 @@ class AmountCard extends StatelessWidget { final valueStyle = moneyStyle( theme.textTheme.displaySmall!, ).copyWith(color: foreground); - final amountField = TextFormField( - initialValue: amount, - autofocus: autofocus, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [DecimalInputFormatter.amount(currency)], - textInputAction: TextInputAction.next, - style: valueStyle, - decoration: InputDecoration( - labelText: label, - errorText: error, - floatingLabelBehavior: FloatingLabelBehavior.always, - prefixStyle: valueStyle, - hintText: currency?.formatPlain(0) ?? '0', - prefixText: currency?.symbol == null ? null : '${currency!.symbol} ', - labelStyle: TextStyle(color: foreground), - floatingLabelStyle: TextStyle(color: foreground), - hintStyle: valueStyle, - filled: false, - border: const UnderlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(vertical: 8), + // The monetary headline keeps its underline independently of form fields. + final amountField = InputDecorationTheme( + data: const InputDecorationThemeData( + filled: true, + border: UnderlineInputBorder(), + helperMaxLines: 3, + errorMaxLines: 3, + ), + child: TextFormField( + initialValue: amount, + autofocus: autofocus, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [DecimalInputFormatter.amount(currency)], + textInputAction: TextInputAction.next, + style: valueStyle, + decoration: InputDecoration( + labelText: label, + errorText: error, + floatingLabelBehavior: FloatingLabelBehavior.always, + prefixStyle: valueStyle, + hintText: currency?.formatPlain(0) ?? '0', + prefixText: currency?.symbol == null ? null : '${currency!.symbol} ', + labelStyle: TextStyle(color: foreground), + floatingLabelStyle: TextStyle(color: foreground), + hintStyle: valueStyle, + filled: false, + border: const UnderlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + onChanged: onAmountChanged, ), - onChanged: onAmountChanged, ); final field = heroTag == null ? amountField diff --git a/lib/presentation/widgets/balances_panel.dart b/lib/presentation/widgets/balances_panel.dart index ccb6d60..ab3f774 100644 --- a/lib/presentation/widgets/balances_panel.dart +++ b/lib/presentation/widgets/balances_panel.dart @@ -439,24 +439,22 @@ class _TransferExplanation extends StatelessWidget { final isMe = debtor == ledger.me?.id; // Lifted out of the widget tree rather than written inline. - final rest = transfer.amountMinor == net.abs() - ? '' - : ', plus the other suggested payments'; + final completion = transfer.amountMinor == net.abs() + ? 'settles this balance' + : 'covers part of this balance'; final why = simplified ? '${isMe ? 'You owe' : '$name owes'} ' - '${formatMoneyAbs(currency, net)} in total across this group. ' - 'Rather than paying several people separately, that whole amount ' - 'is cleared by paying ${ledger.nameOf(creditor)} ' - '${formatMoney(currency, transfer.amountMinor)}$rest.' + '${formatMoneyAbs(currency, net)} overall. This ' + '${formatMoney(currency, transfer.amountMinor)} payment to ' + '${ledger.nameOf(creditor)} $completion.' : '${isMe ? 'You owe' : '$name owes'} ${ledger.nameOf(creditor)} ' - '${formatMoney(currency, transfer.amountMinor)}, from the ' - 'expenses the two of you shared, each way netted against the ' - 'other.'; + '${formatMoney(currency, transfer.amountMinor)} after ' + 'offsetting your shared expenses.'; final caveat = simplified - ? 'This can name someone you never directly owed. It is the shortest ' - 'set of payments that leaves everybody square.' - : 'Only the expenses these two shared count here. Turn on "Suggest ' - 'the fewest payments" in group settings to settle in fewer steps.'; + ? 'Simplifying debts keeps the same balances. You may pay someone ' + 'you did not directly owe.' + : 'Only shared expenses count. Enable "Suggest the fewest payments" ' + 'in group settings for fewer steps.'; return DraggableScrollableSheet( expand: false, diff --git a/lib/presentation/widgets/brand_mark.dart b/lib/presentation/widgets/brand_mark.dart index 47b94e7..102fb33 100644 --- a/lib/presentation/widgets/brand_mark.dart +++ b/lib/presentation/widgets/brand_mark.dart @@ -1,6 +1,9 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_svg/flutter_svg.dart'; +/// The shared product tagline, matching the landing-page headline. +const brandTagline = 'Split expenses with friends, free forever.'; + /// The OpenSplit mark: a ring with one diagonal cut through it. class BrandMark extends StatelessWidget { const BrandMark({super.key, this.size = 24, this.color, this.semanticsLabel}); diff --git a/lib/presentation/widgets/create_group_sheet.dart b/lib/presentation/widgets/create_group_sheet.dart index cfb9814..de8c8a2 100644 --- a/lib/presentation/widgets/create_group_sheet.dart +++ b/lib/presentation/widgets/create_group_sheet.dart @@ -94,65 +94,66 @@ class _CreateGroupSheetState extends ConsumerState<_CreateGroupSheet> { _name.text.trim().isNotEmpty && (!needsName || _yourName.text.trim().isNotEmpty); - return Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + insets.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('New group', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 20), - TextField( - controller: _name, - autofocus: true, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Group name', - hintText: 'Goa trip, Flat 4B, …', - ), - onChanged: (_) => setState(() {}), - ), - if (needsName) ...[ - const SizedBox(height: 16), + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + insets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('New group', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 20), TextField( - controller: _yourName, + controller: _name, + autofocus: true, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( - labelText: 'Your name', - // Said out loud, because it is true: this is not a field about - // this group. - helperText: 'How everyone in your groups will see you.', + labelText: 'Group name', + hintText: 'Goa trip, Flat 4B, …', ), onChanged: (_) => setState(() {}), ), - ], - const SizedBox(height: 16), - CurrencyPicker( - value: _currency, - label: 'Default currency', - onChanged: (code) => setState(() => _currency = code), - ), - const SizedBox(height: 8), - Text( - 'Expenses can be in any currency. This is only what totals are ' - 'summarised in.', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_error != null) ...[ - const SizedBox(height: 12), + if (needsName) ...[ + const SizedBox(height: 16), + TextField( + controller: _yourName, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Your name', + // Said out loud, because it is true: this is not a field about + // this group. + helperText: 'Your name in all groups.', + ), + onChanged: (_) => setState(() {}), + ), + ], + const SizedBox(height: 16), + CurrencyPicker( + value: _currency, + label: 'Default currency', + onChanged: (code) => setState(() => _currency = code), + ), + const SizedBox(height: 8), Text( - _error!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, + 'Summary currency. Expenses can use any currency.', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _saving || !ready ? null : () => _create(accountName), + child: const Text('Create group'), ), ], - const SizedBox(height: 20), - FilledButton( - onPressed: _saving || !ready ? null : () => _create(accountName), - child: const Text('Create group'), - ), - ], + ), ), ); } diff --git a/lib/presentation/widgets/empty_state.dart b/lib/presentation/widgets/empty_state.dart index 66ef9cd..21b3631 100644 --- a/lib/presentation/widgets/empty_state.dart +++ b/lib/presentation/widgets/empty_state.dart @@ -93,7 +93,7 @@ Widget emptyStateDarkPreview() => MaterialApp( body: EmptyState( icon: Icons.groups_outlined, title: 'No groups yet', - message: 'Make one for a trip, a flat, or a single dinner.', + message: 'Create a group for a trip, flat or dinner.', ), ), ); diff --git a/lib/presentation/widgets/group_cover.dart b/lib/presentation/widgets/group_cover.dart index 7b35429..92b91da 100644 --- a/lib/presentation/widgets/group_cover.dart +++ b/lib/presentation/widgets/group_cover.dart @@ -17,18 +17,17 @@ import 'category_icon.dart'; /// categories, so a trip fills with flights and dinners and a flat with bills /// and groceries, and the picture follows the group as it goes on. Before /// anything is spent it uses the group's own avatar. The layout is seeded by -/// the group's id, so the same group looks the same on every device. Both the -/// list card and header crop the same fixed composition, including in flight. +/// the group's id. Cards and headers share one sparse composition in flight. class GroupCover extends ConsumerWidget { const GroupCover({super.key, required this.ledger, this.patternFade = 0}); final GroupLedger ledger; - /// Softens the pattern under list text: zero in the header, one in a row. + /// Softens the pattern beneath overlaid content; zero keeps full contrast. final double patternFade; /// How many of the most frequent categories make up the pattern. - static const _motifs = 5; + static const _motifs = 3; static const _artworkSize = Size(640, 192); @override @@ -63,18 +62,20 @@ class GroupCover extends ConsumerWidget { return ExcludeSemantics( child: ColoredBox( - color: scheme.primaryContainer, + color: Color.lerp( + scheme.primaryContainer, + scheme.surfaceContainerLow, + patternFade, + )!, child: IconTheme.merge( - data: IconThemeData( - color: scheme.onPrimaryContainer.withValues(alpha: 0.18), - ), + data: IconThemeData(color: scheme.onPrimaryContainer), child: ShaderMask( blendMode: BlendMode.dstIn, shaderCallback: (bounds) => LinearGradient( colors: [ - Colors.black.withValues(alpha: 1 - 0.65 * patternFade), - Colors.black.withValues(alpha: 1 - 0.65 * patternFade), - Colors.black.withValues(alpha: 1 - 0.2 * patternFade), + 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), @@ -116,38 +117,40 @@ class GroupCover extends ConsumerWidget { ]; } - /// A staggered grid with each motif nudged, turned and sized by a generator - /// seeded from [seed]. + /// Six accents, with small variations seeded from the group's identity. static List _pattern( Size size, List motifs, String seed, ) { - const cell = 80.0; + const positions = [ + (0.11, 0.18), + (0.33, 0.70), + (0.47, 0.13), + (0.64, 0.67), + (0.81, 0.22), + (0.90, 0.71), + ]; final random = Random(stableHash(seed)); - final columns = (size.width / cell).ceil() + 1; - final rows = (size.height / cell).ceil() + 1; return [ - for (var row = 0; row < rows; row++) - for (var column = 0; column < columns; column++) - _placed( - random, - motifs, - Offset( - column * cell + (row.isOdd ? cell / 2 : 0) - cell / 2, - row * cell - cell / 4, - ), + for (var index = 0; index < positions.length; index++) + _placed( + random, + motifs[index % motifs.length], + Offset( + positions[index].$1 * size.width, + positions[index].$2 * size.height, ), + ), ]; } static Widget _placed( Random random, - List motifs, + Widget Function(double size) motif, Offset origin, ) { final size = 32.0 + random.nextInt(3) * 8; - final motif = motifs[random.nextInt(motifs.length)]; return Positioned( left: origin.dx + random.nextDouble() * 16, top: origin.dy + random.nextDouble() * 16, @@ -159,7 +162,7 @@ class GroupCover extends ConsumerWidget { } } -/// Shares the entire cover, with its thumbnail corners opening into the header. +/// Shares a cover between routes, animating its corners and pattern contrast. class GroupCoverHero extends StatelessWidget { const GroupCoverHero({ super.key, diff --git a/lib/presentation/widgets/group_standing.dart b/lib/presentation/widgets/group_standing.dart index 5200497..524ac01 100644 --- a/lib/presentation/widgets/group_standing.dart +++ b/lib/presentation/widgets/group_standing.dart @@ -21,12 +21,16 @@ class GroupStanding extends StatelessWidget { required this.ledger, required this.currencies, this.style, + this.overflow = TextOverflow.ellipsis, }); final GroupLedger ledger; final Map currencies; final TextStyle? style; + /// Use [TextOverflow.clip] in rows that can grow to fit wrapped balances. + final TextOverflow overflow; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -61,6 +65,7 @@ class GroupStanding extends StatelessWidget { figures: owed, direction: 1, style: base, + overflow: overflow, ), if (owing.isNotEmpty) _Direction( @@ -68,6 +73,7 @@ class GroupStanding extends StatelessWidget { figures: owing, direction: -1, style: base, + overflow: overflow, ), ], ); @@ -82,6 +88,7 @@ class _Direction extends StatelessWidget { required this.figures, required this.direction, required this.style, + required this.overflow, }); final String lead; @@ -93,6 +100,7 @@ class _Direction extends StatelessWidget { final int direction; final TextStyle style; + final TextOverflow overflow; @override Widget build(BuildContext context) { @@ -123,7 +131,7 @@ class _Direction extends StatelessWidget { TextSpan(text: joined, style: figureStyle), ], ), - overflow: TextOverflow.ellipsis, + overflow: overflow, style: style.copyWith(color: scheme.onSurfaceVariant), ), ), diff --git a/lib/presentation/widgets/identity_choices.dart b/lib/presentation/widgets/identity_choices.dart index 501df96..3a61963 100644 --- a/lib/presentation/widgets/identity_choices.dart +++ b/lib/presentation/widgets/identity_choices.dart @@ -201,9 +201,8 @@ class _IdentityChoicesState extends ConsumerState { const SizedBox(height: 8), Text( widget.guestNote ?? - 'Everything works and shared groups are synchronized. This ' - 'device is the only way back into the guest account until ' - 'you add an email address or Google account.', + 'Guests can do everything. Add email or Google to recover ' + 'your account on another device.', textAlign: TextAlign.center, style: Theme.of( context, diff --git a/lib/presentation/widgets/link_account_prompt.dart b/lib/presentation/widgets/link_account_prompt.dart index 9a0578f..fbf3ad8 100644 --- a/lib/presentation/widgets/link_account_prompt.dart +++ b/lib/presentation/widgets/link_account_prompt.dart @@ -33,12 +33,10 @@ class LinkAccountPrompt extends ConsumerWidget { icon: Icons.lock_outline, title: 'Protect access to this guest account', message: kIsWeb - ? 'Your groups synchronize, but this browser holds the only ' - 'way back in. Clearing its data can leave you unable to ' - 'reach the guest account.' - : 'Your groups synchronize, but this phone holds the only ' - 'way back in. Losing it or reinstalling can leave you ' - 'unable to reach the guest account.', + ? 'Clearing browser data can lock you out. Add email or Google ' + 'for recovery.' + : 'Losing this phone or reinstalling can lock you out. Add email ' + 'or Google for recovery.', actions: [ TextButton( onPressed: () => diff --git a/lib/presentation/widgets/notification_invitation.dart b/lib/presentation/widgets/notification_invitation.dart index a51d535..931068b 100644 --- a/lib/presentation/widgets/notification_invitation.dart +++ b/lib/presentation/widgets/notification_invitation.dart @@ -17,12 +17,9 @@ Future offerNotifications(BuildContext context, WidgetRef ref) async { context: context, builder: (context) => AlertDialog( icon: const Icon(Icons.notifications_none), - title: const Text('Get told when they add something?'), + title: const Text('Group notifications?'), content: const Text( - 'Now that someone else is in this group, OpenSplit can let you know ' - 'when they add an expense or settle up.\n\n' - 'The server only ever sends an id. The notification text is put ' - 'together on your device, so nothing about what you spend leaves it.', + 'Get notified when someone adds an expense or records a payment.', ), actions: [ TextButton( diff --git a/lib/presentation/widgets/save_account_form.dart b/lib/presentation/widgets/save_account_form.dart index e61b2d8..82b9e8f 100644 --- a/lib/presentation/widgets/save_account_form.dart +++ b/lib/presentation/widgets/save_account_form.dart @@ -96,15 +96,12 @@ class _SaveAccountFormState extends ConsumerState { title: Text('Sign in as $who?'), content: Text( count > 0 - ? 'That already has an OpenSplit account, so this signs you in ' - 'to it and this guest account ends.\n\n' - 'Your place in ${count == 1 ? 'your group' : 'all $count ' - 'groups'} comes with you, balances and all. In ' - 'any group that account is already in, your guest place ' - 'stays behind as a placeholder under its name.' - : 'That already has an OpenSplit account, so this signs you in ' - 'to it and this guest account ends. It is in no groups, ' - 'so there is nothing to bring along.', + ? 'Signing in ends your guest account. Your ' + '${count == 1 ? 'group' : '$count groups'} and balances ' + 'carry over.\n\nWhere that account is already a member, ' + 'your guest place remains as a placeholder.' + : 'Signing in ends your guest account. It has no groups ' + 'to transfer.', ), actions: [ TextButton( diff --git a/lib/presentation/widgets/shared_element.dart b/lib/presentation/widgets/shared_element.dart index 0545f1e..4f0a76e 100644 --- a/lib/presentation/widgets/shared_element.dart +++ b/lib/presentation/widgets/shared_element.dart @@ -35,7 +35,9 @@ class SharedText extends StatelessWidget { final Object tag; final String text; final TextStyle style; - final int maxLines; + + /// The visible line limit; null lets the label wrap without truncation. + final int? maxLines; final Widget? child; @override @@ -101,7 +103,7 @@ class _TextEndpoint extends StatelessWidget { final String text; final TextStyle style; final TextScaler textScaler; - final int maxLines; + final int? maxLines; final Widget? child; @override @@ -114,7 +116,9 @@ class _TextEndpoint extends StatelessWidget { style: style, textScaler: textScaler, maxLines: maxLines, - overflow: TextOverflow.ellipsis, + overflow: maxLines == null + ? TextOverflow.clip + : TextOverflow.ellipsis, ), ); } diff --git a/site/404.html b/site/404.html index 2c0b14f..1da936e 100644 --- a/site/404.html +++ b/site/404.html @@ -79,7 +79,7 @@

That page is not here

OpenSplit - +