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
87 changes: 87 additions & 0 deletions app/lib/backend/http/api/announcements.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'dart:convert';

import 'package:omi/backend/http/shared.dart';
import 'package:omi/env/env.dart';
import 'package:omi/models/announcement.dart';

Future<List<Announcement>> getAppChangelogs({
String? fromVersion,
String? toVersion,
int limit = 5,
}) async {
String url;
if (fromVersion != null && toVersion != null) {
final encodedFromVersion = Uri.encodeComponent(fromVersion);
final encodedToVersion = Uri.encodeComponent(toVersion);
url = "${Env.apiBaseUrl}v1/announcements/changelogs?from_version=$encodedFromVersion&to_version=$encodedToVersion";
} else {
url = "${Env.apiBaseUrl}v1/announcements/changelogs?limit=$limit";
}

var res = await makeApiCall(
url: url,
headers: {},
body: '',
method: 'GET',
);

if (res == null || res.statusCode != 200) {
return [];
}

final List<dynamic> data = jsonDecode(res.body);
return data.map((json) => Announcement.fromJson(json)).toList();
}

/// Get feature announcements for a specific version.
/// For firmware updates: returns features explaining new device behavior.
/// For app updates: returns features explaining major new app functionality.
Future<List<Announcement>> getFeatureAnnouncements({
required String version,
required String versionType, // 'app' or 'firmware'
String? deviceModel,
}) async {
var url = "${Env.apiBaseUrl}v1/announcements/features?version=$version&version_type=$versionType";
if (deviceModel != null) {
url += "&device_model=$deviceModel";
}
Comment on lines +44 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The query parameters version and deviceModel are not URL-encoded. This can lead to issues if they contain special characters (e.g., a + in a version string like 1.0.0+123 would be misinterpreted as a space). It's safer to use Uri.encodeComponent for all query parameter values, or construct the URI using its constructor with a queryParameters map to handle encoding automatically and robustly.

Suggested change
var url = "${Env.apiBaseUrl}v1/announcements/features?version=$version&version_type=$versionType";
if (deviceModel != null) {
url += "&device_model=$deviceModel";
}
final queryParameters = {
'version': version,
'version_type': versionType,
if (deviceModel != null) 'device_model': deviceModel,
};
final url = Uri.parse('${Env.apiBaseUrl}v1/announcements/features').replace(queryParameters: queryParameters).toString();


var res = await makeApiCall(
url: url,
headers: {},
body: '',
method: 'GET',
);

if (res == null || res.statusCode != 200) {
return [];
}

final List<dynamic> data = jsonDecode(res.body);
return data.map((json) => Announcement.fromJson(json)).toList();
}

/// Get active, non-expired general announcements.
/// If lastCheckedAt is provided, only returns announcements created after that time.
Future<List<Announcement>> getGeneralAnnouncements({
DateTime? lastCheckedAt,
}) async {
var url = "${Env.apiBaseUrl}v1/announcements/general";
if (lastCheckedAt != null) {
url += "?last_checked_at=${Uri.encodeComponent(lastCheckedAt.toUtc().toIso8601String())}";
}

var res = await makeApiCall(
url: url,
headers: {},
body: '',
method: 'GET',
);

if (res == null || res.statusCode != 200) {
return [];
}

final List<dynamic> data = jsonDecode(res.body);
return data.map((json) => Announcement.fromJson(json)).toList();
}
46 changes: 35 additions & 11 deletions app/lib/backend/preferences.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import 'dart:convert';

import 'package:flutter/material.dart';

import 'package:collection/collection.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand All @@ -13,9 +11,7 @@ import 'package:omi/backend/schema/message.dart';
import 'package:omi/backend/schema/person.dart';
import 'package:omi/models/custom_stt_config.dart';
import 'package:omi/models/stt_provider.dart';
import 'package:omi/services/wals.dart';
import 'package:omi/utils/logger.dart';
import 'package:omi/utils/platform/platform_service.dart';

class SharedPreferencesUtil {
static final SharedPreferencesUtil _instance = SharedPreferencesUtil._internal();
Expand Down Expand Up @@ -50,7 +46,7 @@ class SharedPreferencesUtil {
}
}

bool get hasPersonaCreated => getBool('hasPersonaCreated') ?? false;
bool get hasPersonaCreated => getBool('hasPersonaCreated');

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

Expand Down Expand Up @@ -261,7 +257,7 @@ class SharedPreferencesUtil {

setGptCompletionCache(String key, String value) => saveString('gptCompletionCache:$key', value);

bool get optInAnalytics => getBool('optInAnalytics') ?? (PlatformService.isDesktop ? false : true);
bool get optInAnalytics => getBool('optInAnalytics');

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

Expand Down Expand Up @@ -498,7 +494,7 @@ class SharedPreferencesUtil {
}

ServerConversation? get modifiedConversationDetails {
final String conversation = getString('modifiedConversationDetails') ?? '';
final String conversation = getString('modifiedConversationDetails');
if (conversation.isEmpty) return null;
return ServerConversation.fromJson(jsonDecode(conversation));
}
Expand All @@ -525,20 +521,20 @@ class SharedPreferencesUtil {

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

bool get calendarIntegrationEnabled => getBool('calendarIntegrationEnabled') ?? false;
bool get calendarIntegrationEnabled => getBool('calendarIntegrationEnabled');

// Calendar UI Settings
set showEventsWithNoParticipants(bool value) => saveBool('showEventsWithNoParticipants', value);

bool get showEventsWithNoParticipants => getBool('showEventsWithNoParticipants') ?? false;
bool get showEventsWithNoParticipants => getBool('showEventsWithNoParticipants');

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

bool get showMeetingsInMenuBar => getBool('showMeetingsInMenuBar') ?? true;
bool get showMeetingsInMenuBar => getBool('showMeetingsInMenuBar');

set enabledCalendarIds(List<String> value) => saveStringList('enabledCalendarIds', value);

List<String> get enabledCalendarIds => getStringList('enabledCalendarIds') ?? [];
List<String> get enabledCalendarIds => getStringList('enabledCalendarIds');

//--------------------------------- Auth ------------------------------------//

Expand Down Expand Up @@ -568,6 +564,34 @@ class SharedPreferencesUtil {

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

//--------------------------- Announcements ---------------------------------//

// Last known app version - used to detect app upgrades
// Empty string means fresh install
String get lastKnownAppVersion => getString('lastKnownAppVersion');

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

// Last known firmware version - used to detect firmware upgrades
String get lastKnownFirmwareVersion => getString('lastKnownFirmwareVersion');

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

// Last time general announcements were checked
DateTime? get lastAnnouncementCheckTime {
final str = getString('lastAnnouncementCheckTime');
if (str.isEmpty) return null;
return DateTime.tryParse(str);
}

set lastAnnouncementCheckTime(DateTime? value) {
if (value == null) {
remove('lastAnnouncementCheckTime');
} else {
saveString('lastAnnouncementCheckTime', value.toUtc().toIso8601String());
}
}

//--------------------------- Setters & Getters -----------------------------//

String getString(String key, {String defaultValue = ''}) => _preferences?.getString(key) ?? defaultValue;
Expand Down
2 changes: 2 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import 'package:omi/pages/payments/payment_method_provider.dart';
import 'package:omi/pages/persona/persona_provider.dart';
import 'package:omi/pages/settings/ai_app_generator_provider.dart';
import 'package:omi/providers/action_items_provider.dart';
import 'package:omi/providers/announcement_provider.dart';
import 'package:omi/providers/app_provider.dart';
import 'package:omi/providers/auth_provider.dart';
import 'package:omi/providers/calendar_provider.dart';
Expand Down Expand Up @@ -369,6 +370,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
ChangeNotifierProvider(create: (context) => FolderProvider()),
ChangeNotifierProvider(create: (context) => LocaleProvider()),
ChangeNotifierProvider(create: (context) => VoiceRecorderProvider()),
ChangeNotifierProvider(create: (context) => AnnouncementProvider()),
],
builder: (context, child) {
return WithForegroundTask(
Expand Down
150 changes: 150 additions & 0 deletions app/lib/models/announcement.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import 'package:json_annotation/json_annotation.dart';

part 'announcement.g.dart';

enum AnnouncementType {
changelog,
feature,
announcement,
}

// Changelog content models
@JsonSerializable(fieldRename: FieldRename.snake)
class ChangelogItem {
final String title;
final String description;
final String? icon;

ChangelogItem({
required this.title,
required this.description,
this.icon,
});

factory ChangelogItem.fromJson(Map<String, dynamic> json) => _$ChangelogItemFromJson(json);
Map<String, dynamic> toJson() => _$ChangelogItemToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class ChangelogContent {
final String title;
final List<ChangelogItem> changes;

ChangelogContent({
required this.title,
required this.changes,
});

factory ChangelogContent.fromJson(Map<String, dynamic> json) => _$ChangelogContentFromJson(json);
Map<String, dynamic> toJson() => _$ChangelogContentToJson(this);
}

// Feature content models
@JsonSerializable(fieldRename: FieldRename.snake)
class FeatureStep {
final String title;
final String description;
final String? imageUrl;
final String? videoUrl;
final String? highlightText;

FeatureStep({
required this.title,
required this.description,
this.imageUrl,
this.videoUrl,
this.highlightText,
});

factory FeatureStep.fromJson(Map<String, dynamic> json) => _$FeatureStepFromJson(json);
Map<String, dynamic> toJson() => _$FeatureStepToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class FeatureContent {
final String title;
final List<FeatureStep> steps;

FeatureContent({
required this.title,
required this.steps,
});

factory FeatureContent.fromJson(Map<String, dynamic> json) => _$FeatureContentFromJson(json);
Map<String, dynamic> toJson() => _$FeatureContentToJson(this);
}

// Announcement content models
@JsonSerializable(fieldRename: FieldRename.snake)
class AnnouncementCTA {
final String text;
final String action;

AnnouncementCTA({
required this.text,
required this.action,
});

factory AnnouncementCTA.fromJson(Map<String, dynamic> json) => _$AnnouncementCTAFromJson(json);
Map<String, dynamic> toJson() => _$AnnouncementCTAToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class AnnouncementContent {
final String title;
final String body;
final String? imageUrl;
final AnnouncementCTA? cta;

AnnouncementContent({
required this.title,
required this.body,
this.imageUrl,
this.cta,
});

factory AnnouncementContent.fromJson(Map<String, dynamic> json) => _$AnnouncementContentFromJson(json);
Map<String, dynamic> toJson() => _$AnnouncementContentToJson(this);
}

// Main announcement model
@JsonSerializable(fieldRename: FieldRename.snake)
class Announcement {
final String id;
final AnnouncementType type;
final DateTime createdAt;
@JsonKey(defaultValue: true)
final bool active;

// Version triggers
final String? appVersion;
final String? firmwareVersion;
@JsonKey(defaultValue: [])
final List<String>? deviceModels;

// For general announcements
final DateTime? expiresAt;

// Raw content - parsed based on type
final Map<String, dynamic> content;

Announcement({
required this.id,
required this.type,
required this.createdAt,
this.active = true,
this.appVersion,
this.firmwareVersion,
this.deviceModels,
this.expiresAt,
required this.content,
});

factory Announcement.fromJson(Map<String, dynamic> json) => _$AnnouncementFromJson(json);
Map<String, dynamic> toJson() => _$AnnouncementToJson(this);

// Type-specific content getters
ChangelogContent get changelogContent => ChangelogContent.fromJson(content);
FeatureContent get featureContent => FeatureContent.fromJson(content);
AnnouncementContent get announcementContent => AnnouncementContent.fromJson(content);
}
Loading