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
4 changes: 4 additions & 0 deletions app/lib/backend/preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ class SharedPreferencesUtil {

set devLogsToFileEnabled(bool value) => saveBool('devLogsToFileEnabled', value);

String get customBackendUrl => getString('customBackendUrl');

set customBackendUrl(String value) => saveString('customBackendUrl', value);

bool get permissionStoreRecordingsEnabled => getBool('permissionStoreRecordingsEnabled');

set permissionStoreRecordingsEnabled(bool value) => saveBool('permissionStoreRecordingsEnabled', value);
Expand Down
38 changes: 37 additions & 1 deletion app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -10632,5 +10632,41 @@
}
}
},
"tasksMarkComplete": "Marked as complete"
"tasksMarkComplete": "Marked as complete",
"selfHostedBackendSectionTitle": "Self-Hosted Backend",
"@selfHostedBackendSectionTitle": {
"description": "Section header for self-hosted backend URL setting in Developer Settings"
},
"selfHostedBackendSubtitle": "Override the API URL to use your own backend instead of api.omi.me. Leave empty to use the default. Requires app restart to take effect.",
"@selfHostedBackendSubtitle": {
"description": "Description shown under the Self-Hosted Backend section header"
},
"selfHostedBackendUrlHint": "https://your-backend.example.com",
"@selfHostedBackendUrlHint": {
"description": "Placeholder hint text for the backend URL input field"
},
"selfHostedBackendSaveButton": "Save Backend URL",
"@selfHostedBackendSaveButton": {
"description": "Button label to save the custom backend URL"
},
"selfHostedBackendSavedRestart": "Backend URL saved — restart app to apply",
"@selfHostedBackendSavedRestart": {
"description": "Snackbar shown after saving a custom backend URL"
},
"selfHostedBackendClearedRestart": "Backend URL cleared — restart app to apply",
"@selfHostedBackendClearedRestart": {
"description": "Snackbar shown after clearing the custom backend URL"
},
"selfHostedBackendRestoredRestart": "Restored default backend — restart app to apply",
"@selfHostedBackendRestoredRestart": {
"description": "Snackbar shown after clearing URL via save with empty field"
},
"selfHostedBackendHttpError": "URL must start with http:// or https://",
"@selfHostedBackendHttpError": {
"description": "Validation error when backend URL does not start with http"
},
"selfHostedBackendNote": "Note: your backend must have LOCAL_DEVELOPMENT=true to accept tokens from the Omi app.",
"@selfHostedBackendNote": {
"description": "Informational note shown below the backend URL save button"
}
}
10 changes: 9 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,16 @@ Future _init() async {

await SharedPreferencesUtil.init();

// Self-hosted backend URL — must be after SharedPreferencesUtil.init()
final customBackend = SharedPreferencesUtil().customBackendUrl;
if (customBackend.isNotEmpty) {
Env.overrideApiBaseUrl(customBackend);
debugPrint('Self-hosted backend: using $customBackend');
}

// TestFlight environment detection — must be after SharedPreferencesUtil.init()
if (F.env == Environment.prod) {
// Skip when a custom backend URL is already set — user intent takes priority.
if (F.env == Environment.prod && customBackend.isEmpty) {
final isTestFlight = await EnvironmentDetector.isTestFlight();
if (isTestFlight) {
Env.isTestFlight = true;
Expand Down
121 changes: 121 additions & 0 deletions app/lib/pages/settings/developer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,23 @@ class DeveloperSettingsPage extends StatefulWidget {
}

class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
final _backendUrlController = TextEditingController();

@override
void initState() {
_backendUrlController.text = SharedPreferencesUtil().customBackendUrl;
WidgetsBinding.instance.addPostFrameCallback((_) async {
context.read<McpProvider>().fetchKeys();
});
super.initState();
}

@override
void dispose() {
_backendUrlController.dispose();
super.dispose();
}

Widget _buildSectionContainer({required List<Widget> children}) {
return Container(
decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(12)),
Expand Down Expand Up @@ -603,6 +612,118 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
),
const SizedBox(height: 32),

// Self-Hosted Backend Section
_buildSectionHeader(
context.l10n.selfHostedBackendSectionTitle,
subtitle: context.l10n.selfHostedBackendSubtitle,
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(14)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF2A2A2E),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: FaIcon(FontAwesomeIcons.server, color: Colors.grey.shade400, size: 16),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.backendUrlLabel,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
Env.apiBaseUrl ?? 'https://api.omi.me',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _backendUrlController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: context.l10n.selfHostedBackendUrlHint,
hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 14),
filled: true,
fillColor: const Color(0xFF2A2A2E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
suffixIcon: _backendUrlController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, color: Colors.grey.shade500, size: 18),
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
setState(() {});
AppSnackbar.showSnackbar(context.l10n.selfHostedBackendClearedRestart);
},
Comment on lines +676 to +681
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Clear button sets URL to empty string, breaking live API calls

Env.overrideApiBaseUrl('') sets _apiBaseUrlOverride = ''. Because Env.apiBaseUrl uses _apiBaseUrlOverride ?? _instance.apiBaseUrl, an empty string is non-null and passes the ?? check, so all API calls from this point forward will construct requests against '' and fail immediately — before the required restart.

The snackbar already communicates that a restart is needed, so the live Env.overrideApiBaseUrl call on clear is not just redundant but actively harmful. Remove it:

Suggested change
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
Env.overrideApiBaseUrl('');
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},

)
: null,
),
onChanged: (_) => setState(() {}),
keyboardType: TextInputType.url,
autocorrect: false,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
final url = _backendUrlController.text.trim().replaceAll(RegExp(r'/+$'), '');
if (url.isNotEmpty && !url.startsWith('http')) {
AppSnackbar.showSnackbar(context.l10n.selfHostedBackendHttpError);
return;
}
SharedPreferencesUtil().customBackendUrl = url;
setState(() {});
AppSnackbar.showSnackbar(
url.isEmpty
? context.l10n.selfHostedBackendRestoredRestart
: context.l10n.selfHostedBackendSavedRestart,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2A2A2E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
child: Text(context.l10n.selfHostedBackendSaveButton, style: const TextStyle(fontWeight: FontWeight.w500)),
),
),
const SizedBox(height: 10),
Text(
context.l10n.selfHostedBackendNote,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
],
),
),
Comment on lines +615 to +724
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded user-facing strings must use l10n

All user-visible strings in this section are hardcoded English. The codebase rule requires context.l10n.keyName for every user-facing string — there are at least 8 violations in this block:

  • 'Self-Hosted Backend' (section header)
  • 'Backend URL' (row title)
  • 'https://your-backend.example.com' (hint text)
  • 'Save Backend URL' (button label)
  • 'Note: your backend must have LOCAL_DEVELOPMENT=true…'
  • 'Backend URL cleared — restart app to apply'
  • 'URL must start with http:// or https://'
  • 'Restored default backend — restart app to apply' / 'Backend URL saved — restart app to apply'

Add the corresponding keys to app/lib/l10n/app_en.arb and provide translations for all 33 non-English locales per the project's localization policy.

Context Used: Flutter localization - all user-facing strings mus... (source)

const SizedBox(height: 32),

// Debug Logs Section
_buildSectionHeader(context.l10n.debugAndDiagnostics),
Container(
Expand Down