diff --git a/app/lib/backend/http/api/announcements.dart b/app/lib/backend/http/api/announcements.dart new file mode 100644 index 0000000000..cf924c4d0c --- /dev/null +++ b/app/lib/backend/http/api/announcements.dart @@ -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> 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 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> 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"; + } + + var res = await makeApiCall( + url: url, + headers: {}, + body: '', + method: 'GET', + ); + + if (res == null || res.statusCode != 200) { + return []; + } + + final List 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> 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 data = jsonDecode(res.body); + return data.map((json) => Announcement.fromJson(json)).toList(); +} diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 9066b623d9..cecd9bdefa 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -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(); @@ -50,7 +46,7 @@ class SharedPreferencesUtil { } } - bool get hasPersonaCreated => getBool('hasPersonaCreated') ?? false; + bool get hasPersonaCreated => getBool('hasPersonaCreated'); set hasPersonaCreated(bool value) => saveBool('hasPersonaCreated', value); @@ -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); @@ -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)); } @@ -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 value) => saveStringList('enabledCalendarIds', value); - List get enabledCalendarIds => getStringList('enabledCalendarIds') ?? []; + List get enabledCalendarIds => getStringList('enabledCalendarIds'); //--------------------------------- Auth ------------------------------------// @@ -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; diff --git a/app/lib/main.dart b/app/lib/main.dart index 437ae6bf28..1c522ba064 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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'; @@ -369,6 +370,7 @@ class _MyAppState extends State with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => FolderProvider()), ChangeNotifierProvider(create: (context) => LocaleProvider()), ChangeNotifierProvider(create: (context) => VoiceRecorderProvider()), + ChangeNotifierProvider(create: (context) => AnnouncementProvider()), ], builder: (context, child) { return WithForegroundTask( diff --git a/app/lib/models/announcement.dart b/app/lib/models/announcement.dart new file mode 100644 index 0000000000..0aebcac4ca --- /dev/null +++ b/app/lib/models/announcement.dart @@ -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 json) => _$ChangelogItemFromJson(json); + Map toJson() => _$ChangelogItemToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class ChangelogContent { + final String title; + final List changes; + + ChangelogContent({ + required this.title, + required this.changes, + }); + + factory ChangelogContent.fromJson(Map json) => _$ChangelogContentFromJson(json); + Map 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 json) => _$FeatureStepFromJson(json); + Map toJson() => _$FeatureStepToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class FeatureContent { + final String title; + final List steps; + + FeatureContent({ + required this.title, + required this.steps, + }); + + factory FeatureContent.fromJson(Map json) => _$FeatureContentFromJson(json); + Map 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 json) => _$AnnouncementCTAFromJson(json); + Map 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 json) => _$AnnouncementContentFromJson(json); + Map 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? deviceModels; + + // For general announcements + final DateTime? expiresAt; + + // Raw content - parsed based on type + final Map 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 json) => _$AnnouncementFromJson(json); + Map 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); +} diff --git a/app/lib/models/announcement.g.dart b/app/lib/models/announcement.g.dart new file mode 100644 index 0000000000..ca6f2ec8c1 --- /dev/null +++ b/app/lib/models/announcement.g.dart @@ -0,0 +1,133 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'announcement.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChangelogItem _$ChangelogItemFromJson(Map json) => + ChangelogItem( + title: json['title'] as String, + description: json['description'] as String, + icon: json['icon'] as String?, + ); + +Map _$ChangelogItemToJson(ChangelogItem instance) => + { + 'title': instance.title, + 'description': instance.description, + 'icon': instance.icon, + }; + +ChangelogContent _$ChangelogContentFromJson(Map json) => + ChangelogContent( + title: json['title'] as String, + changes: (json['changes'] as List) + .map((e) => ChangelogItem.fromJson(e as Map)) + .toList(), + ); + +Map _$ChangelogContentToJson(ChangelogContent instance) => + { + 'title': instance.title, + 'changes': instance.changes, + }; + +FeatureStep _$FeatureStepFromJson(Map json) => FeatureStep( + title: json['title'] as String, + description: json['description'] as String, + imageUrl: json['image_url'] as String?, + videoUrl: json['video_url'] as String?, + highlightText: json['highlight_text'] as String?, + ); + +Map _$FeatureStepToJson(FeatureStep instance) => + { + 'title': instance.title, + 'description': instance.description, + 'image_url': instance.imageUrl, + 'video_url': instance.videoUrl, + 'highlight_text': instance.highlightText, + }; + +FeatureContent _$FeatureContentFromJson(Map json) => + FeatureContent( + title: json['title'] as String, + steps: (json['steps'] as List) + .map((e) => FeatureStep.fromJson(e as Map)) + .toList(), + ); + +Map _$FeatureContentToJson(FeatureContent instance) => + { + 'title': instance.title, + 'steps': instance.steps, + }; + +AnnouncementCTA _$AnnouncementCTAFromJson(Map json) => + AnnouncementCTA( + text: json['text'] as String, + action: json['action'] as String, + ); + +Map _$AnnouncementCTAToJson(AnnouncementCTA instance) => + { + 'text': instance.text, + 'action': instance.action, + }; + +AnnouncementContent _$AnnouncementContentFromJson(Map json) => + AnnouncementContent( + title: json['title'] as String, + body: json['body'] as String, + imageUrl: json['image_url'] as String?, + cta: json['cta'] == null + ? null + : AnnouncementCTA.fromJson(json['cta'] as Map), + ); + +Map _$AnnouncementContentToJson( + AnnouncementContent instance) => + { + 'title': instance.title, + 'body': instance.body, + 'image_url': instance.imageUrl, + 'cta': instance.cta, + }; + +Announcement _$AnnouncementFromJson(Map json) => Announcement( + id: json['id'] as String, + type: $enumDecode(_$AnnouncementTypeEnumMap, json['type']), + createdAt: DateTime.parse(json['created_at'] as String), + active: json['active'] as bool? ?? true, + appVersion: json['app_version'] as String?, + firmwareVersion: json['firmware_version'] as String?, + deviceModels: (json['device_models'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + expiresAt: json['expires_at'] == null + ? null + : DateTime.parse(json['expires_at'] as String), + content: json['content'] as Map, + ); + +Map _$AnnouncementToJson(Announcement instance) => + { + 'id': instance.id, + 'type': _$AnnouncementTypeEnumMap[instance.type]!, + 'created_at': instance.createdAt.toIso8601String(), + 'active': instance.active, + 'app_version': instance.appVersion, + 'firmware_version': instance.firmwareVersion, + 'device_models': instance.deviceModels, + 'expires_at': instance.expiresAt?.toIso8601String(), + 'content': instance.content, + }; + +const _$AnnouncementTypeEnumMap = { + AnnouncementType.changelog: 'changelog', + AnnouncementType.feature: 'feature', + AnnouncementType.announcement: 'announcement', +}; diff --git a/app/lib/pages/announcements/announcement_dialog.dart b/app/lib/pages/announcements/announcement_dialog.dart new file mode 100644 index 0000000000..b72dd34a13 --- /dev/null +++ b/app/lib/pages/announcements/announcement_dialog.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:omi/models/announcement.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; + +class AnnouncementDialog extends StatelessWidget { + final Announcement announcement; + final VoidCallback? onDismiss; + final VoidCallback? onCTAPressed; + + const AnnouncementDialog({ + super.key, + required this.announcement, + this.onDismiss, + this.onCTAPressed, + }); + + /// Show the announcement dialog. + static Future show( + BuildContext context, + Announcement announcement, { + VoidCallback? onDismiss, + VoidCallback? onCTAPressed, + }) { + bool dismissed = false; + + void markDismissed() { + if (!dismissed) { + dismissed = true; + onDismiss?.call(); + } + } + + return showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black87, + builder: (context) => AnnouncementDialog( + announcement: announcement, + onDismiss: markDismissed, + onCTAPressed: () { + dismissed = true; // Don't call onDismiss if CTA was pressed + onCTAPressed?.call(); + }, + ), + ).then((_) { + // Called when dialog closes for any reason (barrier tap, back button, etc.) + markDismissed(); + }); + } + + @override + Widget build(BuildContext context) { + final content = announcement.announcementContent; + final hasImage = content.imageUrl != null; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Container( + constraints: const BoxConstraints(maxWidth: 360), + decoration: BoxDecoration( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Image at top (if available) + if (hasImage) _buildImage(content.imageUrl!), + + // Close button (if no image) + if (!hasImage) _buildCloseButton(context), + + // Content + Padding( + padding: EdgeInsets.fromLTRB(24, hasImage ? 24 : 8, 24, 24), + child: Column( + children: [ + // Title + Text( + content.title, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + // Body + Text( + content.body, + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 15, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + if (content.cta != null) ...[ + const SizedBox(height: 28), + _buildCTAButton(context, content.cta!), + ], + const SizedBox(height: 8), + // Dismiss text button + TextButton( + onPressed: () { + onDismiss?.call(); + Navigator.pop(context); + }, + child: Text( + 'Maybe Later', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCloseButton(BuildContext context) { + return Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 8, right: 8), + child: IconButton( + onPressed: () { + onDismiss?.call(); + Navigator.pop(context); + }, + icon: Icon( + Icons.close, + color: Colors.grey.shade500, + size: 24, + ), + ), + ), + ); + } + + Widget _buildImage(String imageUrl) { + return Stack( + children: [ + CachedNetworkImage( + imageUrl: imageUrl, + width: double.infinity, + height: 180, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + height: 180, + color: const Color(0xFF2A2A2E), + child: const Center( + child: CircularProgressIndicator( + color: ResponsiveHelper.purplePrimary, + strokeWidth: 2, + ), + ), + ), + errorWidget: (context, url, error) => Container( + height: 180, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + ResponsiveHelper.purpleAccent.withValues(alpha: 0.3), + ], + ), + ), + child: const Icon( + Icons.campaign_outlined, + color: Colors.white54, + size: 48, + ), + ), + ), + // Close button overlaid on image + Positioned( + top: 8, + right: 8, + child: Builder( + builder: (ctx) => Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: () { + onDismiss?.call(); + Navigator.pop(ctx); + }, + icon: const Icon( + Icons.close, + color: Colors.white70, + size: 20, + ), + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(), + ), + ), + ), + ), + ], + ); + } + + Widget _buildCTAButton(BuildContext context, AnnouncementCTA cta) { + return SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: () { + onCTAPressed?.call(); + Navigator.pop(context); + _handleCTAAction(context, cta.action); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(26), + ), + elevation: 0, + ), + child: Text( + cta.text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + void _handleCTAAction(BuildContext context, String action) async { + final uri = Uri.tryParse(action); + if (uri == null) { + debugPrint('Invalid URL: $action'); + return; + } + + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('Failed to open URL: $e'); + } + } +} diff --git a/app/lib/pages/announcements/changelog_sheet.dart b/app/lib/pages/announcements/changelog_sheet.dart new file mode 100644 index 0000000000..58ed80f269 --- /dev/null +++ b/app/lib/pages/announcements/changelog_sheet.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import 'package:omi/models/announcement.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; + +class ChangelogSheet extends StatefulWidget { + final List? changelogs; + final Future> Function()? changelogsFuture; + + const ChangelogSheet({ + super.key, + this.changelogs, + this.changelogsFuture, + }) : assert(changelogs != null || changelogsFuture != null); + + /// Show the changelog sheet as a modal bottom sheet with pre-loaded data. + static Future show(BuildContext context, List changelogs) { + if (changelogs.isEmpty) return Future.value(); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ChangelogSheet(changelogs: changelogs), + ); + } + + static Future showWithLoading( + BuildContext context, + Future> Function() fetchChangelogs, + ) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ChangelogSheet(changelogsFuture: fetchChangelogs), + ); + } + + @override + State createState() => _ChangelogSheetState(); +} + +class _ChangelogSheetState extends State { + late PageController _pageController; + int _currentPage = 0; + List _orderedChangelogs = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + + if (widget.changelogs != null) { + _initializeWithChangelogs(widget.changelogs!); + } else if (widget.changelogsFuture != null) { + _loadChangelogs(); + } + } + + void _initializeWithChangelogs(List changelogs) { + // Reverse so oldest is at index 0, newest at the end + _orderedChangelogs = changelogs.reversed.toList(); + // Start on the last page (latest version) + _currentPage = _orderedChangelogs.isEmpty ? 0 : _orderedChangelogs.length - 1; + _pageController = PageController(initialPage: _currentPage); + _isLoading = false; + } + + Future _loadChangelogs() async { + try { + final changelogs = await widget.changelogsFuture!(); + if (changelogs.isEmpty) { + if (mounted) { + Navigator.pop(context); + } + return; + } + if (mounted) { + setState(() { + _initializeWithChangelogs(changelogs); + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to load changelogs'; + _isLoading = false; + }); + } + } + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return Container( + height: screenHeight * 0.75, + decoration: const BoxDecoration( + color: ResponsiveHelper.backgroundSecondary, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + _buildHeader(), + Expanded( + child: _isLoading + ? _buildLoadingState() + : _error != null + ? _buildErrorState() + : PageView.builder( + controller: _pageController, + itemCount: _orderedChangelogs.length, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + itemBuilder: (context, index) { + return _buildChangelogPage(_orderedChangelogs[index]); + }, + ), + ), + if (!_isLoading && _error == null) _buildFooter(), + ], + ), + ); + } + + Widget _buildHeader() { + String title = "What's New"; + if (!_isLoading && _orderedChangelogs.isNotEmpty) { + final version = _orderedChangelogs[_currentPage].appVersion ?? ''; + title = "What's New in $version"; + } + + return Container( + padding: const EdgeInsets.fromLTRB(20, 12, 12, 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: ResponsiveHelper.backgroundTertiary, width: 1), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _isLoading + ? Shimmer.fromColors( + baseColor: ResponsiveHelper.backgroundTertiary, + highlightColor: ResponsiveHelper.backgroundSecondary, + child: Container( + width: 180, + height: 22, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + ) + : Text( + title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon( + Icons.close, + color: ResponsiveHelper.textSecondary, + size: 24, + ), + ), + ], + ), + ); + } + + Widget _buildLoadingState() { + return Shimmer.fromColors( + baseColor: ResponsiveHelper.backgroundTertiary, + highlightColor: ResponsiveHelper.backgroundSecondary, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shimmer for 3 changelog items + for (int i = 0; i < 3; i++) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon placeholder + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 12), + // Title placeholder + Expanded( + child: Container( + height: 20, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Description placeholder + Padding( + padding: const EdgeInsets.only(left: 36), + child: Column( + children: [ + Container( + height: 14, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 14, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + if (i < 2) const SizedBox(height: 32), + ], + ], + ), + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: Colors.grey.shade500, + size: 48, + ), + const SizedBox(height: 16), + Text( + _error ?? 'Something went wrong', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 16, + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + setState(() { + _isLoading = true; + _error = null; + }); + _loadChangelogs(); + }, + child: const Text('Retry'), + ), + ], + ), + ); + } + + Widget _buildChangelogPage(Announcement changelog) { + final content = changelog.changelogContent; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < content.changes.length; i++) ...[ + _buildChangeItem(content.changes[i]), + if (i < content.changes.length - 1) const SizedBox(height: 24), + ], + ], + ), + ); + } + + Widget _buildChangeItem(ChangelogItem item) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row with emoji icon + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + item.icon ?? '✨', + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + item.title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Description + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + item.description, + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 15, + height: 1.5, + ), + ), + ), + ], + ); + } + + Widget _buildFooter() { + if (_orderedChangelogs.length <= 1) { + return const SizedBox(height: 24); + } + + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: ResponsiveHelper.backgroundTertiary, width: 1), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Left arrow - go to older version (lower index) + _buildNavigationButton( + icon: Icons.chevron_left, + enabled: _currentPage > 0, + onTap: () { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + // Version indicator and page dots + Column( + children: [ + Text( + 'Version ${_orderedChangelogs[_currentPage].appVersion ?? ''}', + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _orderedChangelogs.length, + (index) => _buildPageDot(index), + ), + ), + ], + ), + // Right arrow - go to newer version (higher index) + _buildNavigationButton( + icon: Icons.chevron_right, + enabled: _currentPage < _orderedChangelogs.length - 1, + onTap: () { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ], + ), + ); + } + + Widget _buildNavigationButton({ + required IconData icon, + required bool enabled, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: enabled ? ResponsiveHelper.purplePrimary : ResponsiveHelper.backgroundTertiary, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: enabled ? ResponsiveHelper.textPrimary : ResponsiveHelper.textQuaternary, + size: 24, + ), + ), + ); + } + + Widget _buildPageDot(int index) { + final isActive = index == _currentPage; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 20 : 6, + height: 6, + decoration: BoxDecoration( + color: isActive ? ResponsiveHelper.textPrimary : ResponsiveHelper.textQuaternary, + borderRadius: BorderRadius.circular(3), + ), + ); + } +} diff --git a/app/lib/pages/announcements/feature_screen.dart b/app/lib/pages/announcements/feature_screen.dart new file mode 100644 index 0000000000..9c576eca54 --- /dev/null +++ b/app/lib/pages/announcements/feature_screen.dart @@ -0,0 +1,643 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:omi/models/announcement.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; + +class FeatureScreen extends StatefulWidget { + final Announcement feature; + final VoidCallback? onComplete; + + const FeatureScreen({ + super.key, + required this.feature, + this.onComplete, + }); + + /// Show the feature screen as a full-screen modal. + static Future show(BuildContext context, Announcement feature) { + return Navigator.of(context).push( + PageRouteBuilder( + fullscreenDialog: true, + pageBuilder: (context, animation, secondaryAnimation) => FeatureScreen(feature: feature), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + )), + child: child, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 300), + ), + ); + } + + @override + State createState() => _FeatureScreenState(); +} + +class _FeatureScreenState extends State with SingleTickerProviderStateMixin { + late PageController _pageController; + late AnimationController _buttonAnimationController; + int _currentPage = 0; + + FeatureContent get content => widget.feature.featureContent; + List get steps => content.steps; + + // Determine display mode: + // - Paged mode: Multiple steps where each has its own image/video (swipeable pages) + // - List mode with header: First step has image, rest are text-only (single scrollable page) + // - List mode: No steps have images (single scrollable page with numbered list) + // - Single page: Only one step (with or without image) + + int get _stepsWithMedia => steps.where((step) => step.imageUrl != null || step.videoUrl != null).length; + + // Use paged mode only if multiple steps have their own images + bool get _usePagedMode => _stepsWithMedia > 1; + + // List mode with header image: first step has image, others don't + bool get _useListModeWithHeader => + steps.length > 1 && _stepsWithMedia == 1 && (steps.first.imageUrl != null || steps.first.videoUrl != null); + + bool get _showPageIndicators => _usePagedMode; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _buttonAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + } + + @override + void dispose() { + _pageController.dispose(); + _buttonAnimationController.dispose(); + super.dispose(); + } + + void _nextPage() { + if (_usePagedMode && _currentPage < steps.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _complete(); + } + } + + void _complete() { + widget.onComplete?.call(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ResponsiveHelper.backgroundPrimary, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded(child: _buildContent()), + _buildFooter(), + ], + ), + ), + ); + } + + Widget _buildContent() { + // Single step: show single page (with or without image) + if (steps.length == 1) { + return _buildStepPage(steps.first); + } + + // List mode with header image: first step has image, rest are text-only + if (_useListModeWithHeader) { + return _buildListModeWithHeader(); + } + + // Paged mode: multiple steps with their own images (swipeable) + if (_usePagedMode) { + return PageView.builder( + controller: _pageController, + itemCount: steps.length, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + itemBuilder: (context, index) { + return _buildStepPage(steps[index]); + }, + ); + } + + // List mode: no images, all steps as numbered list + return _buildListMode(); + } + + /// List mode with header - shows image at top, then all steps as a list below + Widget _buildListModeWithHeader() { + final firstStep = steps.first; + + return LayoutBuilder( + builder: (context, constraints) { + final imageHeight = (constraints.maxHeight * 0.35).clamp(160.0, 240.0); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + // Header image + if (firstStep.imageUrl != null) _buildImage(firstStep.imageUrl!, imageHeight), + if (firstStep.videoUrl != null) _buildVideoPlaceholder(firstStep.videoUrl!, imageHeight), + const SizedBox(height: 24), + // Main title centered + Center( + child: Text( + content.title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.25, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + // List of all steps (including first one as text) + for (int i = 0; i < steps.length; i++) ...[ + _buildListItem(steps[i], i + 1), + if (i < steps.length - 1) const SizedBox(height: 20), + ], + const SizedBox(height: 24), + ], + ), + ); + }, + ); + } + + /// List mode - shows all steps vertically on one scrollable page (no images) + Widget _buildListMode() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + // Main title centered + Center( + child: Text( + content.title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.25, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 32), + // List of steps + for (int i = 0; i < steps.length; i++) ...[ + _buildListItem(steps[i], i + 1), + if (i < steps.length - 1) const SizedBox(height: 24), + ], + const SizedBox(height: 24), + ], + ), + ); + } + + /// A single item in list mode (numbered step) + Widget _buildListItem(FeatureStep step, int number) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Number badge + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '$number', + style: const TextStyle( + color: ResponsiveHelper.purplePrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 14), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + const SizedBox(height: 6), + _buildListDescription(step), + ], + ), + ), + ], + ); + } + + Widget _buildListDescription(FeatureStep step) { + final description = step.description; + final highlightText = step.highlightText; + + if (highlightText != null && description.contains(highlightText)) { + final parts = description.split(highlightText); + return RichText( + text: TextSpan( + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 15, + height: 1.5, + ), + children: [ + TextSpan(text: parts.first), + TextSpan( + text: highlightText, + style: TextStyle( + color: ResponsiveHelper.purplePrimary, + fontWeight: FontWeight.w600, + backgroundColor: ResponsiveHelper.purplePrimary.withValues(alpha: 0.15), + ), + ), + if (parts.length > 1) TextSpan(text: parts.sublist(1).join(highlightText)), + ], + ), + ); + } + + return Text( + description, + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 15, + height: 1.5, + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 12, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Feature title (shows overall feature name) + Expanded( + child: Text( + content.title, + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Close button + Material( + color: Colors.transparent, + child: InkWell( + onTap: _complete, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + child: const Icon( + Icons.close, + color: ResponsiveHelper.textSecondary, + size: 24, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStepPage(FeatureStep step) { + return LayoutBuilder( + builder: (context, constraints) { + // Calculate image height based on available space + final availableHeight = constraints.maxHeight; + final imageHeight = (availableHeight * 0.45).clamp(200.0, 320.0); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 16), + // Image or Video with better sizing + if (step.imageUrl != null) _buildImage(step.imageUrl!, imageHeight), + if (step.videoUrl != null) _buildVideoPlaceholder(step.videoUrl!, imageHeight), + if (step.imageUrl != null || step.videoUrl != null) const SizedBox(height: 32), + // Title with better typography + Text( + step.title, + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 26, + fontWeight: FontWeight.w700, + height: 1.25, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + // Description with optional highlighted text + _buildDescription(step), + const SizedBox(height: 32), + ], + ), + ), + ); + }, + ); + } + + Widget _buildImage(String imageUrl, double height) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary, + borderRadius: BorderRadius.circular(20), + ), + child: const Center( + child: CircularProgressIndicator( + color: ResponsiveHelper.purplePrimary, + strokeWidth: 2, + ), + ), + ), + errorWidget: (context, url, error) => Container( + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.image_not_supported_outlined, + color: ResponsiveHelper.textQuaternary, + size: 48, + ), + ), + ), + ), + ); + } + + Widget _buildVideoPlaceholder(String videoUrl, double height) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Play button with glow effect + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.4), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 36, + ), + ), + ], + ), + ); + } + + Widget _buildDescription(FeatureStep step) { + final description = step.description; + final highlightText = step.highlightText; + + if (highlightText != null && description.contains(highlightText)) { + final parts = description.split(highlightText); + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 16, + height: 1.6, + letterSpacing: 0.1, + ), + children: [ + TextSpan(text: parts.first), + TextSpan( + text: highlightText, + style: TextStyle( + color: ResponsiveHelper.purplePrimary, + fontWeight: FontWeight.w600, + backgroundColor: ResponsiveHelper.purplePrimary.withValues(alpha: 0.15), + ), + ), + if (parts.length > 1) TextSpan(text: parts.sublist(1).join(highlightText)), + ], + ), + ); + } + + return Text( + description, + style: const TextStyle( + color: ResponsiveHelper.textSecondary, + fontSize: 16, + height: 1.6, + letterSpacing: 0.1, + ), + textAlign: TextAlign.center, + ); + } + + Widget _buildFooter() { + final isLastStep = !_usePagedMode || _currentPage == steps.length - 1; + + return Container( + padding: EdgeInsets.fromLTRB(24, 16, 24, _showPageIndicators ? 20 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Page indicators (only for paged mode with multiple steps) + if (_showPageIndicators) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + steps.length, + (index) => _buildPageDot(index), + ), + ), + const SizedBox(height: 20), + ], + // Primary action button with gradient + _buildActionButton(isLastStep), + ], + ), + ); + } + + Widget _buildActionButton(bool isLastStep) { + return GestureDetector( + onTapDown: (_) => _buttonAnimationController.forward(), + onTapUp: (_) { + _buttonAnimationController.reverse(); + _nextPage(); + }, + onTapCancel: () => _buttonAnimationController.reverse(), + child: AnimatedBuilder( + animation: _buttonAnimationController, + builder: (context, child) { + final scale = 1.0 - (_buttonAnimationController.value * 0.03); + return Transform.scale( + scale: scale, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + ResponsiveHelper.purplePrimary, + Color(0xFF8B5CF6), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + isLastStep ? 'Got it' : 'Continue', + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + if (!isLastStep) ...[ + const SizedBox(width: 8), + const Icon( + Icons.arrow_forward_rounded, + color: Colors.white, + size: 20, + ), + ], + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildPageDot(int index) { + final isActive = index == _currentPage; + final isPast = index < _currentPage; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isActive ? 28 : 8, + height: 8, + decoration: BoxDecoration( + color: isActive + ? ResponsiveHelper.purplePrimary + : isPast + ? ResponsiveHelper.purplePrimary.withValues(alpha: 0.5) + : ResponsiveHelper.backgroundTertiary, + borderRadius: BorderRadius.circular(4), + ), + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index e32e35d0f5..6f07017ab2 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -15,6 +15,7 @@ import 'package:omi/backend/http/api/conversations.dart'; import 'package:omi/backend/http/api/users.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; +import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/backend/schema/geolocation.dart'; import 'package:omi/main.dart'; import 'package:omi/pages/action_items/action_items_page.dart'; @@ -38,9 +39,11 @@ import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/device_provider.dart'; +import 'package:omi/providers/announcement_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/providers/sync_provider.dart'; +import 'package:omi/services/announcement_service.dart'; import 'package:omi/services/notifications.dart'; import 'package:omi/services/notifications/daily_reflection_notification.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; @@ -404,12 +407,46 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker _listenToMessagesFromNotification(); _listenToFreemiumThreshold(); + _checkForAnnouncements(); super.initState(); // After init FlutterForegroundTask.addTaskDataCallback(_onReceiveTaskData); } + void _checkForAnnouncements() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + + await Future.delayed(const Duration(seconds: 2)); + + if (!mounted) return; + + final announcementProvider = Provider.of(context, listen: false); + final deviceProvider = Provider.of(context, listen: false); + await AnnouncementService().checkAndShowAnnouncements( + context, + announcementProvider, + connectedDevice: deviceProvider.connectedDevice, + ); + + // Register callback for device connection to check firmware announcements + deviceProvider.onDeviceConnected = _onDeviceConnectedForAnnouncements; + }); + } + + void _onDeviceConnectedForAnnouncements(BtDevice device) async { + if (!mounted) return; + + final announcementProvider = Provider.of(context, listen: false); + await AnnouncementService().showFirmwareUpdateAnnouncements( + context, + announcementProvider, + device.firmwareRevision, + device.modelNumber, + ); + } + void _listenToFreemiumThreshold() { // Listen to capture provider for freemium threshold events WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1179,6 +1216,11 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker captureProvider.removeListener(_onCaptureProviderChanged); captureProvider.onFreemiumSessionReset = null; } catch (_) {} + // Remove device provider callback + try { + final deviceProvider = Provider.of(context, listen: false); + deviceProvider.onDeviceConnected = null; + } catch (_) {} // Clean up freemium handler _freemiumHandler.dispose(); // Remove foreground task callback to prevent memory leak diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index f60694eb23..12c593455f 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -770,6 +770,7 @@ class _DeveloperSettingsPageState extends State { // Debug Logs Section _buildSectionHeader('Debug & Diagnostics'), + Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( diff --git a/app/lib/pages/settings/settings_drawer.dart b/app/lib/pages/settings/settings_drawer.dart index 9d8e56e345..a3aa2094fa 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -21,12 +21,12 @@ import 'package:omi/widgets/dialog.dart'; import 'package:intercom_flutter/intercom_flutter.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; -import 'package:omi/providers/locale_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:omi/backend/http/api/announcements.dart'; +import 'package:omi/pages/announcements/changelog_sheet.dart'; import 'device_settings.dart'; -import 'wrapped_2025_page.dart'; import '../conversations/sync_page.dart'; enum SettingsMode { @@ -301,8 +301,6 @@ class _SettingsDrawerState extends State { }); } - - Widget _buildOmiModeContent(BuildContext context) { return Consumer(builder: (context, usageProvider, child) { return Column( @@ -471,7 +469,17 @@ class _SettingsDrawerState extends State { await routeToPage(context, const DeveloperSettingsPage()); }, ), - + const Divider(height: 1, color: Color(0xFF3C3C43)), + _buildSettingsItem( + title: "What's New", + icon: const FaIcon(FontAwesomeIcons.solidStar, color: Color(0xFF8E8E93), size: 20), + onTap: () { + ChangelogSheet.showWithLoading( + context, + () => getAppChangelogs(limit: 5), + ); + }, + ), ], ), const SizedBox(height: 32), diff --git a/app/lib/providers/announcement_provider.dart b/app/lib/providers/announcement_provider.dart new file mode 100644 index 0000000000..5d3a530fa7 --- /dev/null +++ b/app/lib/providers/announcement_provider.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:omi/backend/http/api/announcements.dart'; +import 'package:omi/backend/preferences.dart'; +import 'package:omi/models/announcement.dart'; +import 'package:omi/providers/base_provider.dart'; + +class AnnouncementProvider extends BaseProvider { + List _changelogs = []; + List _features = []; + List _generalAnnouncements = []; + + List get changelogs => _changelogs; + List get features => _features; + List get generalAnnouncements => _generalAnnouncements; + + bool _hasAppUpgrade = false; + bool get hasAppUpgrade => _hasAppUpgrade; + + String _previousAppVersion = ''; + String _currentAppVersion = ''; + String get previousAppVersion => _previousAppVersion; + String get currentAppVersion => _currentAppVersion; + + /// Check if the app was upgraded and load changelogs if needed. + /// Returns true if there are changelogs to show. + Future checkForAppUpgrade() async { + try { + final packageInfo = await PackageInfo.fromPlatform(); + // Combine version and build number (e.g., "1.0.510+240") + _currentAppVersion = '${packageInfo.version}+${packageInfo.buildNumber}'; + _previousAppVersion = SharedPreferencesUtil().lastKnownAppVersion; + + if (_previousAppVersion.isEmpty) { + final isExistingUser = SharedPreferencesUtil().onboardingCompleted; + + if (!isExistingUser) { + SharedPreferencesUtil().lastKnownAppVersion = _currentAppVersion; + _hasAppUpgrade = false; + return false; + } + + _previousAppVersion = '1.0.520+598'; + } + + // Check if upgrade occurred + if (_isNewerVersion(_currentAppVersion, _previousAppVersion)) { + _hasAppUpgrade = true; + + _changelogs = await getAppChangelogs(limit: 5); + + final appFeatures = await getFeatureAnnouncements( + version: _currentAppVersion, + versionType: 'app', + ); + _features = [..._features, ...appFeatures]; + + // Update last known version + SharedPreferencesUtil().lastKnownAppVersion = _currentAppVersion; + notifyListeners(); + + return _changelogs.isNotEmpty || appFeatures.isNotEmpty; + } + + _hasAppUpgrade = false; + return false; + } catch (e) { + debugPrint('Error checking for app upgrade: $e'); + return false; + } + } + + /// Check for firmware upgrade and load feature announcements. + Future checkForFirmwareUpgrade(String currentFirmwareVersion, String deviceModel) async { + try { + if (currentFirmwareVersion.isEmpty || currentFirmwareVersion == 'Unknown') { + return false; + } + + final lastKnownFirmware = SharedPreferencesUtil().lastKnownFirmwareVersion; + + if (lastKnownFirmware.isEmpty) { + SharedPreferencesUtil().lastKnownFirmwareVersion = currentFirmwareVersion; + return false; + } + + if (currentFirmwareVersion == lastKnownFirmware) { + return false; + } + + debugPrint('Firmware upgraded from $lastKnownFirmware to $currentFirmwareVersion'); + + // Update stored version + SharedPreferencesUtil().lastKnownFirmwareVersion = currentFirmwareVersion; + + // Fetch feature announcements for the new firmware version + final firmwareFeatures = await getFeatureAnnouncements( + version: currentFirmwareVersion, + versionType: 'firmware', + deviceModel: deviceModel, + ); + + if (firmwareFeatures.isNotEmpty) { + _features = [..._features, ...firmwareFeatures]; + notifyListeners(); + } + + return firmwareFeatures.isNotEmpty; + } catch (e) { + debugPrint('Error checking for firmware upgrade: $e'); + return false; + } + } + + /// Check for general announcements (time-based, not version-gated). + Future checkForGeneralAnnouncements() async { + try { + final lastChecked = SharedPreferencesUtil().lastAnnouncementCheckTime; + _generalAnnouncements = await getGeneralAnnouncements(lastCheckedAt: lastChecked); + notifyListeners(); + return _generalAnnouncements.isNotEmpty; + } catch (e) { + debugPrint('Error checking for general announcements: $e'); + return false; + } + } + + void markAnnouncementsAsSeen() { + SharedPreferencesUtil().lastAnnouncementCheckTime = DateTime.now().toUtc(); + _generalAnnouncements.clear(); + notifyListeners(); + } + + /// Clear changelogs after user has viewed them. + void clearChangelogs() { + _changelogs = []; + _hasAppUpgrade = false; + notifyListeners(); + } + + /// Clear features after user has viewed them. + void clearFeatures() { + _features = []; + notifyListeners(); + } + + /// Compare two version strings. + /// Returns true if v1 is newer than v2. + bool _isNewerVersion(String v1, String v2) { + final t1 = _versionTuple(v1); + final t2 = _versionTuple(v2); + + for (int i = 0; i < t1.length && i < t2.length; i++) { + if (t1[i] > t2[i]) return true; + if (t1[i] < t2[i]) return false; + } + + return t1.length > t2.length; + } + + /// Convert version string to list of integers. + /// Supports formats: + /// - "1.0.10" -> [1, 0, 10, 0] + /// - "v1.0.10" -> [1, 0, 10, 0] + /// - "1.0.510+240" -> [1, 0, 510, 240] + List _versionTuple(String version) { + if (version.isEmpty) return [0, 0, 0, 0]; + + // Remove 'v' prefix if present + version = version.toLowerCase(); + if (version.startsWith('v')) { + version = version.substring(1); + } + + // Extract build number if present (e.g., '1.0.510+240') + int buildNumber = 0; + if (version.contains('+')) { + final parts = version.split('+'); + version = parts[0]; + try { + buildNumber = int.parse(parts[1]); + } catch (e) { + buildNumber = 0; + } + } + + try { + final versionParts = version.split('.').map((p) => int.parse(p)).toList(); + // Pad to 3 components and add build number as 4th + while (versionParts.length < 3) { + versionParts.add(0); + } + versionParts.add(buildNumber); + return versionParts; + } catch (e) { + return [0, 0, 0, 0]; + } + } +} diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index da17afcc12..7d65b84eda 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -49,6 +49,8 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption final Debouncer _disconnectDebouncer = Debouncer(delay: const Duration(milliseconds: 500)); final Debouncer _connectDebouncer = Debouncer(delay: const Duration(milliseconds: 100)); + void Function(BtDevice device)? onDeviceConnected; + DeviceProvider() { ServiceManager.instance().device.subscribe(this, this); } @@ -363,6 +365,8 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption // Check firmware updates _checkFirmwareUpdates(); + + onDeviceConnected?.call(device); } void _handleDeviceConnected(String deviceId) async { diff --git a/app/lib/services/announcement_service.dart b/app/lib/services/announcement_service.dart new file mode 100644 index 0000000000..84128f1bcd --- /dev/null +++ b/app/lib/services/announcement_service.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/pages/announcements/announcement_dialog.dart'; +import 'package:omi/pages/announcements/changelog_sheet.dart'; +import 'package:omi/pages/announcements/feature_screen.dart'; +import 'package:omi/providers/announcement_provider.dart'; + +/// Service that handles announcement detection and display. +/// Call this on app startup and after firmware updates. +class AnnouncementService { + static final AnnouncementService _instance = AnnouncementService._internal(); + factory AnnouncementService() => _instance; + AnnouncementService._internal(); + + bool _isShowingAnnouncement = false; + + /// Check for and display all pending announcements on app startup. + /// Should be called after the user is authenticated and home screen is ready. + Future checkAndShowAnnouncements( + BuildContext context, + AnnouncementProvider provider, { + BtDevice? connectedDevice, + }) async { + if (_isShowingAnnouncement) return; + + try { + // 1. Check for app upgrade and show changelogs + final hasAppUpgrade = await provider.checkForAppUpgrade(); + if (hasAppUpgrade && context.mounted) { + await _showAppUpgradeAnnouncements(context, provider); + } + + // 2. Check for firmware upgrade + if (connectedDevice != null && context.mounted) { + final hasFirmwareFeatures = await provider.checkForFirmwareUpgrade( + connectedDevice.firmwareRevision, + connectedDevice.modelNumber, + ); + if (hasFirmwareFeatures && context.mounted) { + await _showFeatureAnnouncements(context, provider); + } + } + + // 3. Check for general announcements + if (context.mounted) { + final hasAnnouncements = await provider.checkForGeneralAnnouncements(); + if (hasAnnouncements && context.mounted) { + await _showGeneralAnnouncements(context, provider); + } + } + } catch (e) { + debugPrint('Error checking announcements: $e'); + } + } + + /// Show announcements after a firmware update completes. + Future showFirmwareUpdateAnnouncements( + BuildContext context, + AnnouncementProvider provider, + String newFirmwareVersion, + String deviceModel, + ) async { + if (_isShowingAnnouncement) return; + + try { + final hasFeatures = await provider.checkForFirmwareUpgrade( + newFirmwareVersion, + deviceModel, + ); + + if (hasFeatures && context.mounted) { + await _showFeatureAnnouncements(context, provider); + } + } catch (e) { + debugPrint('Error showing firmware announcements: $e'); + } + } + + Future _showAppUpgradeAnnouncements( + BuildContext context, + AnnouncementProvider provider, + ) async { + _isShowingAnnouncement = true; + + try { + // Show feature announcements first (full screen, more important) + if (provider.features.isNotEmpty) { + for (final feature in provider.features.where((f) => f.appVersion != null)) { + if (!context.mounted) break; + await FeatureScreen.show(context, feature); + } + } + + // Then show changelogs (bottom sheet, less intrusive) + if (provider.changelogs.isNotEmpty && context.mounted) { + await ChangelogSheet.show(context, provider.changelogs); + } + + provider.clearChangelogs(); + provider.clearFeatures(); + } finally { + _isShowingAnnouncement = false; + } + } + + Future _showFeatureAnnouncements( + BuildContext context, + AnnouncementProvider provider, + ) async { + _isShowingAnnouncement = true; + + try { + for (final feature in provider.features.where((f) => f.firmwareVersion != null)) { + if (!context.mounted) break; + await FeatureScreen.show(context, feature); + } + + provider.clearFeatures(); + } finally { + _isShowingAnnouncement = false; + } + } + + Future _showGeneralAnnouncements( + BuildContext context, + AnnouncementProvider provider, + ) async { + _isShowingAnnouncement = true; + + try { + for (final announcement in List.from(provider.generalAnnouncements)) { + if (!context.mounted) break; + await AnnouncementDialog.show(context, announcement); + } + + provider.markAnnouncementsAsSeen(); + } finally { + _isShowingAnnouncement = false; + } + } +} diff --git a/backend/database/announcements.py b/backend/database/announcements.py new file mode 100644 index 0000000000..62fa2052b1 --- /dev/null +++ b/backend/database/announcements.py @@ -0,0 +1,305 @@ +from datetime import datetime, timezone +from typing import List, Optional + +from google.cloud.firestore_v1 import FieldFilter + +from ._client import db +from models.announcement import Announcement, AnnouncementType + + +def get_announcement_by_id(announcement_id: str) -> Optional[Announcement]: + """Get a single announcement by ID.""" + doc_ref = db.collection("announcements").document(announcement_id) + doc = doc_ref.get() + if doc.exists: + return Announcement.from_dict(doc.to_dict()) + return None + + +def get_app_changelogs(from_version: str, to_version: str) -> List[Announcement]: + """ + Get all app changelog announcements between two versions. + Returns changelogs where from_version < app_version <= to_version. + Sorted by app_version descending (newest first). + """ + announcements_ref = db.collection("announcements") + query = announcements_ref.where(filter=FieldFilter("type", "==", AnnouncementType.CHANGELOG.value)).where( + filter=FieldFilter("active", "==", True) + ) + + docs = query.stream() + changelogs = [] + + for doc in docs: + data = doc.to_dict() + app_version = data.get("app_version") + # Skip entries without app_version, then filter by version range + if ( + app_version + and _compare_versions(from_version, app_version) < 0 + and _compare_versions(app_version, to_version) <= 0 + ): + changelogs.append(Announcement.from_dict(data)) + + # Sort by version descending (newest first) + changelogs.sort(key=lambda x: _version_tuple(x.app_version), reverse=True) + return changelogs + + +def get_recent_changelogs(limit: int = 5) -> List[Announcement]: + """ + Get the most recent app changelog announcements. + Returns up to `limit` changelogs sorted by version descending + """ + announcements_ref = db.collection("announcements") + query = announcements_ref.where(filter=FieldFilter("type", "==", AnnouncementType.CHANGELOG.value)).where( + filter=FieldFilter("active", "==", True) + ) + + docs = query.stream() + changelogs = [] + + for doc in docs: + data = doc.to_dict() + app_version = data.get("app_version") + if app_version: + changelogs.append(Announcement.from_dict(data)) + + # Sort by version descending + changelogs.sort(key=lambda x: _version_tuple(x.app_version), reverse=True) + + # Return only the most recent N changelogs + return changelogs[:limit] + + +def get_firmware_features(firmware_version: str, device_model: Optional[str] = None) -> List[Announcement]: + """ + Get feature announcements for a specific firmware version. + Optionally filter by device model. + """ + announcements_ref = db.collection("announcements") + query = ( + announcements_ref.where(filter=FieldFilter("type", "==", AnnouncementType.FEATURE.value)) + .where(filter=FieldFilter("active", "==", True)) + .where(filter=FieldFilter("firmware_version", "==", firmware_version)) + ) + + docs = query.stream() + features = [] + + for doc in docs: + data = doc.to_dict() + announcement = Announcement.from_dict(data) + + # Filter by device model if specified + if device_model and announcement.device_models: + if device_model not in announcement.device_models: + continue + + features.append(announcement) + + return features + + +def get_app_features(app_version: str) -> List[Announcement]: + """ + Get feature announcements for a specific app version. + """ + announcements_ref = db.collection("announcements") + query = ( + announcements_ref.where(filter=FieldFilter("type", "==", AnnouncementType.FEATURE.value)) + .where(filter=FieldFilter("active", "==", True)) + .where(filter=FieldFilter("app_version", "==", app_version)) + ) + + docs = query.stream() + return [Announcement.from_dict(doc.to_dict()) for doc in docs] + + +def get_general_announcements(last_checked_at: Optional[datetime] = None) -> List[Announcement]: + """ + Get active, non-expired general announcements. + If last_checked_at is provided, only returns announcements created after that time. + """ + now = datetime.now(timezone.utc) + announcements_ref = db.collection("announcements") + query = announcements_ref.where(filter=FieldFilter("type", "==", AnnouncementType.ANNOUNCEMENT.value)).where( + filter=FieldFilter("active", "==", True) + ) + + docs = query.stream() + announcements = [] + + for doc in docs: + data = doc.to_dict() + announcement = Announcement.from_dict(data) + + # Skip if created before last check + if last_checked_at and announcement.created_at <= last_checked_at: + continue + + # Skip if expired + if announcement.expires_at and announcement.expires_at < now: + continue + + announcements.append(announcement) + + # Sort by created_at descending + announcements.sort(key=lambda x: x.created_at, reverse=True) + return announcements + + +def get_all_announcements( + announcement_type: Optional[AnnouncementType] = None, + active_only: bool = False, +) -> List[Announcement]: + """ + Get all announcements with optional filtering. + + Args: + announcement_type: Filter by type (changelog, feature, announcement) + active_only: If True, only return active announcements + """ + announcements_ref = db.collection("announcements") + query = announcements_ref + + if announcement_type: + query = query.where(filter=FieldFilter("type", "==", announcement_type.value)) + + if active_only: + query = query.where(filter=FieldFilter("active", "==", True)) + + docs = query.stream() + announcements = [Announcement.from_dict(doc.to_dict()) for doc in docs] + + # Sort by created_at descending + announcements.sort(key=lambda x: x.created_at, reverse=True) + return announcements + + +def create_announcement(announcement: Announcement) -> Announcement: + """Create a new announcement.""" + doc_ref = db.collection("announcements").document(announcement.id) + doc_ref.set(announcement.to_dict()) + return announcement + + +def update_announcement(announcement_id: str, updates: dict) -> Optional[Announcement]: + """Update an existing announcement.""" + doc_ref = db.collection("announcements").document(announcement_id) + doc = doc_ref.get() + if not doc.exists: + return None + + doc_ref.update(updates) + return get_announcement_by_id(announcement_id) + + +def delete_announcement(announcement_id: str) -> bool: + """Delete an announcement.""" + doc_ref = db.collection("announcements").document(announcement_id) + doc = doc_ref.get() + if not doc.exists: + return False + + doc_ref.delete() + return True + + +def deactivate_announcement(announcement_id: str) -> bool: + """Soft delete - set active to False.""" + doc_ref = db.collection("announcements").document(announcement_id) + doc = doc_ref.get() + if not doc.exists: + return False + + doc_ref.update({"active": False}) + return True + + +# Helper functions for version comparison +def _parse_version(version: str) -> tuple: + """ + Parse version string into semantic tuple, build number, and has_build flag. + + Returns: (semantic_tuple, build_number, has_build) + + Examples: + - '1.0.10' -> ((1, 0, 10), 0, False) + - 'v1.0.10' -> ((1, 0, 10), 0, False) + - '1.0.510+240' -> ((1, 0, 510), 240, True) + """ + if not version: + return ((0, 0, 0), 0, False) + + # Remove 'v' prefix if present + version = version.lstrip("v") + + # Extract build number if present (e.g., '1.0.510+240') + build_number = 0 + has_build = False + if "+" in version: + has_build = True + version, build_str = version.split("+", 1) + try: + build_number = int(build_str) + except ValueError: + build_number = 0 + + try: + parts = version.split(".") + version_parts = tuple(int(p) for p in parts) + # Pad to 3 components + while len(version_parts) < 3: + version_parts = version_parts + (0,) + return (version_parts[:3], build_number, has_build) + except (ValueError, AttributeError): + return ((0, 0, 0), 0, False) + + +def _version_tuple(version: str) -> tuple: + """ + Convert version string to tuple for sorting. + Returns full tuple including build number for proper sorting. + """ + semantic, build, _ = _parse_version(version) + return semantic + (build,) + + +def _compare_versions(v1: str, v2: str) -> int: + """ + Two-pass version comparison. + + Pass 1: Compare semantic versions (major.minor.patch) + Pass 2: If semantic versions are equal, compare build numbers + BUT if either version has no build number, consider them equal + + This allows: + - '1.0.521' to match all builds like '1.0.521+607', '1.0.521+608', etc. + - '1.0.521+607' < '1.0.521+608' (when both have build numbers) + - '1.0.521' < '1.0.522' (semantic comparison) + + Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 + """ + sem1, build1, has_build1 = _parse_version(v1) + sem2, build2, has_build2 = _parse_version(v2) + + # First pass: semantic version comparison + if sem1 < sem2: + return -1 + if sem1 > sem2: + return 1 + + # Semantic versions are equal + # Second pass: build number comparison (only if BOTH have build numbers) + if not has_build1 or not has_build2: + # If either version lacks a build number, consider them equal + # This means '1.0.521' matches '1.0.521+607' + return 0 + + # Both have build numbers, compare them + if build1 < build2: + return -1 + if build1 > build2: + return 1 + return 0 diff --git a/backend/main.py b/backend/main.py index 815ed11f92..1bb3d708db 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,6 +39,7 @@ wrapped, folders, goals, + announcements, ) from utils.other.timeout import TimeoutMiddleware @@ -95,6 +96,7 @@ app.include_router(folders.router) app.include_router(knowledge_graph.router) app.include_router(goals.router) +app.include_router(announcements.router) methods_timeout = { diff --git a/backend/models/announcement.py b/backend/models/announcement.py new file mode 100644 index 0000000000..50b6ceb247 --- /dev/null +++ b/backend/models/announcement.py @@ -0,0 +1,118 @@ +from datetime import datetime +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel + + +class AnnouncementType(str, Enum): + CHANGELOG = "changelog" + FEATURE = "feature" + ANNOUNCEMENT = "announcement" + + +# Changelog content models +class ChangelogItem(BaseModel): + title: str + description: str + icon: Optional[str] = None + + +class ChangelogContent(BaseModel): + title: str + changes: List[ChangelogItem] + + +# Feature content models +class FeatureStep(BaseModel): + title: str + description: str + image_url: Optional[str] = None + video_url: Optional[str] = None + highlight_text: Optional[str] = None + + +class FeatureContent(BaseModel): + title: str + steps: List[FeatureStep] + + +# Announcement content models +class AnnouncementCTA(BaseModel): + text: str + action: str # e.g., "navigate:/settings/premium" or "url:https://example.com" + + +class AnnouncementContent(BaseModel): + title: str + body: str + image_url: Optional[str] = None + cta: Optional[AnnouncementCTA] = None + + +# Main announcement model +class Announcement(BaseModel): + id: str + type: AnnouncementType + created_at: datetime + active: bool = True + + # Version triggers (optional, depends on type) + app_version: Optional[str] = None + firmware_version: Optional[str] = None + device_models: Optional[List[str]] = None + + # For general announcements + expires_at: Optional[datetime] = None + + # Content - will be one of ChangelogContent, FeatureContent, or AnnouncementContent + content: dict + + def get_changelog_content(self) -> ChangelogContent: + return ChangelogContent(**self.content) + + def get_feature_content(self) -> FeatureContent: + return FeatureContent(**self.content) + + def get_announcement_content(self) -> AnnouncementContent: + return AnnouncementContent(**self.content) + + @staticmethod + def from_dict(data: dict) -> "Announcement": + return Announcement( + id=data.get("id"), + type=AnnouncementType(data.get("type")), + created_at=data.get("created_at"), + active=data.get("active", True), + app_version=data.get("app_version"), + firmware_version=data.get("firmware_version"), + device_models=data.get("device_models"), + expires_at=data.get("expires_at"), + content=data.get("content", {}), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "type": self.type.value, + "created_at": self.created_at, + "active": self.active, + "app_version": self.app_version, + "firmware_version": self.firmware_version, + "device_models": self.device_models, + "expires_at": self.expires_at, + "content": self.content, + } + + +# API Response models +class ChangelogResponse(BaseModel): + changelogs: List[Announcement] + + +class FeatureResponse(BaseModel): + features: List[Announcement] + + +class AnnouncementListResponse(BaseModel): + announcements: List[Announcement] diff --git a/backend/routers/announcements.py b/backend/routers/announcements.py new file mode 100644 index 0000000000..eb880e4bac --- /dev/null +++ b/backend/routers/announcements.py @@ -0,0 +1,269 @@ +import os +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Header, HTTPException, Query +from pydantic import BaseModel + +from database.announcements import ( + create_announcement, + deactivate_announcement, + delete_announcement, + get_all_announcements, + get_announcement_by_id, + get_app_changelogs, + get_app_features, + get_firmware_features, + get_general_announcements, + get_recent_changelogs, + update_announcement, +) +from models.announcement import Announcement, AnnouncementType + +router = APIRouter() + + +@router.get("/v1/announcements/changelogs", response_model=List[Announcement]) +async def get_changelogs( + from_version: Optional[str] = Query(None, description="Previous app version (before upgrade)"), + to_version: Optional[str] = Query(None, description="Current app version (after upgrade)"), + limit: int = Query(5, description="Maximum number of changelogs to return (used when from/to not provided)"), +): + """ + Get app changelog announcements. + + If from_version and to_version are provided: + Returns changelogs where from_version < app_version <= to_version. + + If not provided: + Returns the most recent `limit` changelogs. + + Sorted by version descending (newest first). + User sees the latest version's changelog first, can swipe to see older versions. + """ + if from_version and to_version: + changelogs = get_app_changelogs(from_version, to_version) + else: + changelogs = get_recent_changelogs(limit=limit) + return changelogs + + +@router.get("/v1/announcements/features", response_model=List[Announcement]) +async def get_features( + version: str = Query(..., description="Version user upgraded to"), + version_type: str = Query(..., description="Type: 'app' or 'firmware'"), + device_model: Optional[str] = Query(None, description="Device model (for firmware features)"), +): + """ + 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. + """ + if version_type == "firmware": + features = get_firmware_features(version, device_model) + else: + features = get_app_features(version) + + return features + + +@router.get("/v1/announcements/general", response_model=List[Announcement]) +async def get_announcements( + last_checked_at: Optional[str] = Query( + None, description="ISO timestamp of last check (only returns newer announcements)" + ), +): + """ + Get active, non-expired general announcements. + If last_checked_at is provided, only returns announcements created after that time. + + These are time-based announcements (promotions, notices) not tied to versions. + """ + checked_at = None + if last_checked_at: + try: + checked_at = datetime.fromisoformat(last_checked_at.replace("Z", "+00:00")) + except ValueError: + pass + + announcements = get_general_announcements(checked_at) + return announcements + + +# ---------------------------- +# Admin CRUD Endpoints +# ---------------------------- + + +def _verify_admin_key(secret_key: str): + """Verify the secret key matches the ADMIN_KEY environment variable.""" + admin_key = os.getenv("ADMIN_KEY") + if not admin_key or secret_key != admin_key: + raise HTTPException(status_code=403, detail="You are not authorized to perform this action") + + +class CreateAnnouncementRequest(BaseModel): + """Request body for creating an announcement.""" + + id: str + type: AnnouncementType + active: bool = True + app_version: Optional[str] = None + firmware_version: Optional[str] = None + device_models: Optional[List[str]] = None + expires_at: Optional[datetime] = None + content: dict + + +class UpdateAnnouncementRequest(BaseModel): + """Request body for updating an announcement.""" + + active: Optional[bool] = None + app_version: Optional[str] = None + firmware_version: Optional[str] = None + device_models: Optional[List[str]] = None + expires_at: Optional[datetime] = None + content: Optional[dict] = None + + +@router.get("/v1/announcements/all", response_model=List[Announcement], tags=["admin"]) +async def list_all_announcements( + secret_key: str = Header(..., description="Admin secret key"), + announcement_type: Optional[AnnouncementType] = Query(None, description="Filter by type"), + active_only: bool = Query(False, description="Only return active announcements"), +): + """ + List all announcements with optional filtering. + Requires admin authentication via secret-key header. + + Useful for admin dashboard to see all announcements. + """ + _verify_admin_key(secret_key) + + announcements = get_all_announcements( + announcement_type=announcement_type, + active_only=active_only, + ) + return announcements + + +@router.get("/v1/announcements/{announcement_id}", response_model=Announcement, tags=["admin"]) +async def get_announcement( + announcement_id: str, + secret_key: str = Header(..., description="Admin secret key"), +): + """ + Get a single announcement by ID. + Requires admin authentication via secret-key header. + """ + _verify_admin_key(secret_key) + + announcement = get_announcement_by_id(announcement_id) + if not announcement: + raise HTTPException(status_code=404, detail="Announcement not found") + + return announcement + + +@router.post("/v1/announcements", response_model=Announcement, tags=["admin"]) +async def create_announcement_endpoint( + data: CreateAnnouncementRequest, + secret_key: str = Header(..., description="Admin secret key"), +): + """ + Create a new announcement. + Requires admin authentication via secret-key header. + + Content structure depends on type: + - changelog: {"title": "...", "changes": [{"title": "...", "description": "...", "icon": "🔀"}, ...]} + - feature: {"title": "...", "steps": [{"title": "...", "description": "...", "image_url": "...", "highlight_text": "..."}, ...]} + - announcement: {"title": "...", "body": "...", "image_url": "...", "cta": {"text": "...", "action": "..."}} + """ + _verify_admin_key(secret_key) + + # Check if announcement with this ID already exists + existing = get_announcement_by_id(data.id) + if existing: + raise HTTPException(status_code=409, detail=f"Announcement with ID '{data.id}' already exists") + + announcement = Announcement( + id=data.id, + type=data.type, + created_at=datetime.now(timezone.utc), + active=data.active, + app_version=data.app_version, + firmware_version=data.firmware_version, + device_models=data.device_models, + expires_at=data.expires_at, + content=data.content, + ) + + created = create_announcement(announcement) + return created + + +@router.put("/v1/announcements/{announcement_id}", response_model=Announcement, tags=["admin"]) +async def update_announcement_endpoint( + announcement_id: str, + data: UpdateAnnouncementRequest, + secret_key: str = Header(..., description="Admin secret key"), +): + """ + Update an existing announcement. + Requires admin authentication via secret-key header. + Only provided fields will be updated. + """ + _verify_admin_key(secret_key) + + existing = get_announcement_by_id(announcement_id) + if not existing: + raise HTTPException(status_code=404, detail="Announcement not found") + + # Build updates dict with only non-None values + updates = {} + if data.active is not None: + updates["active"] = data.active + if data.app_version is not None: + updates["app_version"] = data.app_version + if data.firmware_version is not None: + updates["firmware_version"] = data.firmware_version + if data.device_models is not None: + updates["device_models"] = data.device_models + if data.expires_at is not None: + updates["expires_at"] = data.expires_at + if data.content is not None: + updates["content"] = data.content + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updated = update_announcement(announcement_id, updates) + return updated + + +@router.delete("/v1/announcements/{announcement_id}", tags=["admin"]) +async def delete_announcement_endpoint( + announcement_id: str, + secret_key: str = Header(..., description="Admin secret key"), + soft_delete: bool = Query(True, description="If true, deactivates instead of permanently deleting"), +): + """ + Delete an announcement. + Requires admin authentication via secret-key header. + + By default, performs a soft delete (sets active=false). + Set soft_delete=false to permanently remove the announcement. + """ + _verify_admin_key(secret_key) + + existing = get_announcement_by_id(announcement_id) + if not existing: + raise HTTPException(status_code=404, detail="Announcement not found") + + if soft_delete: + success = deactivate_announcement(announcement_id) + return {"success": success, "message": "Announcement deactivated"} + else: + success = delete_announcement(announcement_id) + return {"success": success, "message": "Announcement permanently deleted"}