Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions .agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"version": 1,
"images": [
{
"file": "001-mobile-ios-settings-before-light.png",
"platform": "mobile-ios",
"description": "Settings sheet on main (widget-test render, Free plan): person icon on Account, plain Plan & Usage row with no Upgrade, Referral with a NEW tag beside it, one long group of eight rows",
"captured_by": "agent",
"source": "manual"
},
{
"file": "002-mobile-ios-settings-after-light.png",
"platform": "mobile-ios",
"description": "Settings sheet after, iOS simulator (card arm, Free plan, no device): no handle band, Account as name and email, plan card with a full-width Upgrade, Device (Disconnected) / Recording / Notifications / Privacy first",
"captured_by": "agent",
"source": "manual"
}
]
}
3 changes: 3 additions & 0 deletions app/changelog/unreleased/20261015-settings-order.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Settings puts what you use most first, shows your device's battery, and makes upgrading one tap"
}
2 changes: 2 additions & 0 deletions app/docs/ux-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ There are exactly two ways out, and they mean different things.
widget. It owns the top radius (`OmiRadius.xl`), the 36×4 drag handle, the optional title row
with a trailing `OmiCloseButton`, safe-area and keyboard insets, and `isScrollControlled`. Never
a raw `showModalBottomSheet` (`raw-bottom-sheet`) and never a hand-drawn handle.
- The Settings sheet drops the handle band (`showDragHandle: false`): its own header, with the close X,
is the top of the sheet, and it still swipes down to close.
- A sheet nothing may dismiss (a required update) passes `isDismissible: false, enableDrag: false`
and blocks back with `PopScope`, and has no handle that promises a swipe: `showOmiSheet` drops it
itself; `showOmiSurfaceSheet` takes `showDragHandle: false`.
Expand Down
192 changes: 138 additions & 54 deletions app/lib/pages/settings/settings_drawer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ import 'package:provider/provider.dart';

import 'package:omi/backend/preferences.dart';
import 'package:omi/pages/settings/settings_destinations.dart';
import 'package:omi/models/subscription.dart';
import 'package:omi/pages/settings/settings_groups.dart';
import 'package:omi/providers/usage_provider.dart';
import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart';
import 'package:omi/pages/settings/widgets/settings_plan_section.dart';
import 'package:omi/providers/usage_provider.dart';
import 'package:omi/pages/settings/settings_search_index.dart';
import 'package:omi/providers/device_provider.dart';
import 'package:omi/ui/ui.dart';
import 'package:omi/utils/l10n_extensions.dart';
import 'package:omi/utils/platform/platform_service.dart';

/// The Settings sheet: Account, then eight groups (Device, Recording & Transcription, Voice,
/// Notifications & Display, Integrations, Privacy & Data, Help & About, Developer Settings), plus
/// search over every row in Settings and its pages ([settingsSearchEntries]).
/// The Settings sheet: Account and Plan, then the groups people open most (Device, Recording &
/// Transcription, Notifications & Display, Data & Privacy), Voice, Integrations, Memories, Goals,
/// Help & About and Developer Settings, plus search over every row in Settings and its pages
/// ([settingsSearchEntries]).
///
/// Every setting is at most one tap below the sheet: a group row opens its group page
/// (settings_groups.dart), whose rows open the same pages the sheet used to open directly.
Expand All @@ -30,12 +33,15 @@ class SettingsDrawer extends StatefulWidget {
/// Opens Settings; resolves when the sheet closes (callers compare settings after that).
static Future<void> show(BuildContext context) {
// Settings is a grouped list: surface1 rows on the page colour, so the sheet itself is surface0
// (showOmiSheet paints surface1). Same shell otherwise: framework drag handle, own header with
// a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page
// (showOmiSheet paints surface1). Same shell otherwise, minus the handle band: its own header
// with a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page
// opened here repaints the sheet too.
return showOmiSurfaceSheet<void>(
context: context,
surface: () => OmiColors.surface0,
// No handle band: the header (with its close X) is the top of the sheet, which still swipes
// down to close.
showDragHandle: false,
builder: (context) => const FractionallySizedBox(heightFactor: 0.92, child: SettingsDrawer()),
);
}
Expand Down Expand Up @@ -83,7 +89,7 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
// ---------------------------------------------------------------------------------------------
// Rows

/// A top-level row; [key] is the widget key a UI harness taps (`settings_account`,
/// A top-level row; [key] is the widget key a UI harness taps (`settings_row_<row>`,
/// `settings_group_<group>`).
Widget _row(
SettingsDestination destination, {
Expand All @@ -106,11 +112,24 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
);
}

/// Show the subscriber's actual tier on the Plan & Usage row.
/// The plan on the Plan & Usage row: "Free Plan" and the Plus plan name (their section adds an
/// Upgrade button), "Pro" for the other paid plans.
String? _planValue(UsageProvider usage) {
final plan = usage.subscription?.subscription.plan;
if (plan == null || !plan.isPaid) return null;
return currentPlanDisplayName(context, usage.subscription);
final subscription = usage.subscription;
final plan = subscription?.subscription.plan;
if (plan == null) return null;
if (usage.showSubscriptionUI && (plan == PlanType.basic || plan == PlanType.plus)) {
return planDisplayName(plan, subscription!.availablePlans, context.l10n);
}
if (!plan.isPaid) return null;
return currentPlanDisplayName(context, subscription);
}

/// The device on the Device row: its battery when it reports one, else Connected / Disconnected.
String _deviceValue(DeviceProvider device) {
if (!device.isConnected) return context.l10n.disconnected;
final battery = device.batteryLevel;
return battery >= 0 ? '$battery%' : context.l10n.connected;
}

Widget _buildSettings(BuildContext context) {
Expand All @@ -124,62 +143,73 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
children: [
OmiSettingsGroup(
children: [
_row(
SettingsDestination.profile,
key: 'settings_account',
icon: FontAwesomeIcons.solidUser,
title: name.isEmpty ? l10n.account : name,
subtitle: email.isEmpty ? null : email,
_AccountHeader(
key: const ValueKey('settings_account'),
name: name.isEmpty ? l10n.account : name,
email: email.isEmpty ? null : email,
onTap: () => _open(SettingsDestination.profile),
),
],
),
const SizedBox(height: OmiSpacing.xl),
// Plan, referrals and feedback stay one tap from the sheet (David, 2026-09-24).
// Plan stays one tap from the sheet (David, 2026-09-24); for Free and Plus it is a plan card
// with the minutes meter and the Upgrade button.
OmiSettingsGroup(
children: [
_row(
SettingsDestination.planAndUsage,
key: 'settings_row_planAndUsage',
icon: FontAwesomeIcons.chartLine,
title: l10n.planAndUsage,
value: planValue,
),
_row(
SettingsDestination.referral,
key: 'settings_row_referral',
icon: FontAwesomeIcons.gift,
title: l10n.referralProgram,
tag: SettingsTag(l10n.newTag, OmiColors.success),
SettingsPlanSection(
planName: planValue,
onOpen: () => _open(SettingsDestination.planAndUsage),
row: _row(
SettingsDestination.planAndUsage,
key: 'settings_row_planAndUsage',
icon: FontAwesomeIcons.chartLine,
title: l10n.planAndUsage,
value: planValue,
),
),
],
),
const SizedBox(height: OmiSpacing.xl),
// The groups people open most come first: Device and Recording & Transcription are most of
// the visits to Settings, then Notifications & Display and Data & Privacy.
OmiSettingsGroup(
children: [
_row(
SettingsDestination.deviceGroup,
key: 'settings_group_device',
icon: FontAwesomeIcons.bluetooth,
title: l10n.device,
value: _deviceValue(context.watch<DeviceProvider>()),
),
_row(
SettingsDestination.recordingGroup,
key: 'settings_group_recording',
icon: FontAwesomeIcons.microphone,
title: l10n.recordingAndTranscription,
),
_row(
SettingsDestination.voice,
key: 'settings_group_voice',
icon: FontAwesomeIcons.volumeHigh,
title: l10n.assistantVoiceSettingsTitle,
),
_row(
SettingsDestination.notificationsGroup,
key: 'settings_group_notifications',
icon: FontAwesomeIcons.solidBell,
title: l10n.notificationsAndDisplay,
),
_row(
SettingsDestination.privacyGroup,
key: 'settings_group_privacy',
icon: FontAwesomeIcons.shield,
title: l10n.dataAndPrivacy,
),
],
),
const SizedBox(height: OmiSpacing.xl),
OmiSettingsGroup(
children: [
_row(
SettingsDestination.voice,
key: 'settings_group_voice',
icon: FontAwesomeIcons.volumeHigh,
title: l10n.assistantVoiceSettingsTitle,
),
_row(
SettingsDestination.integrations,
key: 'settings_group_integrations',
Expand All @@ -193,12 +223,6 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
icon: FontAwesomeIcons.terminal,
title: l10n.connectAnAgent,
),
_row(
SettingsDestination.privacyGroup,
key: 'settings_group_privacy',
icon: FontAwesomeIcons.shield,
title: l10n.dataAndPrivacy,
),
],
),
const SizedBox(height: OmiSpacing.xl),
Expand All @@ -220,6 +244,7 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
],
),
const SizedBox(height: OmiSpacing.xl),
// Referrals and feedback stay one tap from the sheet (David, 2026-09-24).
OmiSettingsGroup(
children: [
_row(
Expand All @@ -228,6 +253,12 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
icon: FontAwesomeIcons.circleQuestion,
title: l10n.helpAndAbout,
),
_row(
SettingsDestination.referral,
key: 'settings_row_referral',
icon: FontAwesomeIcons.gift,
title: l10n.referralProgram,
),
if (PlatformService.isIntercomSupported)
_row(
SettingsDestination.feedback,
Expand Down Expand Up @@ -324,19 +355,72 @@ class _SettingsDrawerState extends State<SettingsDrawer> {
@override
Widget build(BuildContext context) {
final motion = OmiMotion.of(context);
return Column(
children: [
AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)),
const SizedBox(height: OmiSpacing.xs),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child:
_isSearching && _searchQuery.trim().isNotEmpty ? _buildSearchResults(context) : _buildSettings(context),
return Padding(
padding: const EdgeInsets.only(top: OmiSpacing.sm),
child: Column(
children: [
AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)),
const SizedBox(height: OmiSpacing.xs),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: _isSearching && _searchQuery.trim().isNotEmpty
? _buildSearchResults(context)
: _buildSettings(context),
),
),
],
),
);
}
}

/// Who is signed in, at the top of the sheet: the name large with the email under it, like the plan
/// card below it. No picture: Omi has no profile photos, so a placeholder would only add noise.
/// Tapping it opens Account.
class _AccountHeader extends StatelessWidget {
const _AccountHeader({super.key, required this.name, required this.email, required this.onTap});

final String name;
final String? email;
final VoidCallback onTap;

@override
Widget build(BuildContext context) {
return MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, OmiSpacing.md, OmiSpacing.md, OmiSpacing.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(name, style: OmiType.title3, maxLines: 1, overflow: TextOverflow.ellipsis),
if (email != null) ...[
const SizedBox(height: 2),
Text(
email!,
style: OmiType.subhead.copyWith(color: OmiColors.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
ExcludeSemantics(child: Icon(Icons.chevron_right, size: 20, color: OmiColors.textTertiary)),
],
),
),
),
],
),
);
}
}
Loading