From 8f08922b4403ca0c24f88de9d240137c0e8c9d23 Mon Sep 17 00:00:00 2001 From: Pawan Nagar Date: Thu, 2 Jan 2025 02:00:11 +0530 Subject: [PATCH 1/5] Added notifications grouping feature --- .../android/models/UpcomingNotification.java | 10 ++- .../alarm/NotificationBatchReceiver.java | 3 +- .../MindfulNotificationListenerService.java | 52 ++++++------ lib/core/services/method_channel_service.dart | 10 ++- lib/l10n/app_en.arb | 3 + .../upcoming_notifications_provider.dart | 76 ++++++++++++------ lib/ui/common/default_segmented_button.dart | 79 +++++++++++++++++++ lib/ui/common/sliver_flexible_appbar.dart | 3 +- lib/ui/common/sliver_usage_cards.dart | 58 ++++---------- lib/ui/common/status_label.dart | 49 ++++++++++++ .../batch_notifications_screen.dart | 4 +- .../screens/focus/timeline/session_card.dart | 21 +---- ..._tile.dart => app_notifications_tile.dart} | 43 +++++++--- .../upcoming_notifications_screen.dart | 69 +++++++++++----- 14 files changed, 338 insertions(+), 142 deletions(-) create mode 100644 lib/ui/common/default_segmented_button.dart create mode 100644 lib/ui/common/status_label.dart rename lib/ui/screens/upcoming_notifications/{notifications_group_tile.dart => app_notifications_tile.dart} (77%) diff --git a/android/app/src/main/java/com/mindful/android/models/UpcomingNotification.java b/android/app/src/main/java/com/mindful/android/models/UpcomingNotification.java index f6545af..f9bfbd9 100644 --- a/android/app/src/main/java/com/mindful/android/models/UpcomingNotification.java +++ b/android/app/src/main/java/com/mindful/android/models/UpcomingNotification.java @@ -19,13 +19,17 @@ public class UpcomingNotification { public final String contentText; public final long timeStamp; - // Constructor that initializes the object using StatusBarNotification + /** + * Constructor that initializes the object using StatusBarNotification + * + * @param sbn Status Bar Notification object + */ public UpcomingNotification(@NonNull StatusBarNotification sbn) { Bundle extras = sbn.getNotification().extras; this.packageName = sbn.getPackageName(); - this.titleText = extras.getString(Notification.EXTRA_TITLE, "Null").trim(); - this.contentText = extras.getString(Notification.EXTRA_TEXT, "Null").trim(); this.timeStamp = sbn.getPostTime(); + this.titleText = extras.getCharSequence(Notification.EXTRA_TITLE, "").toString().trim(); + this.contentText = extras.getCharSequence(Notification.EXTRA_TEXT, "").toString().trim(); } /** diff --git a/android/app/src/main/java/com/mindful/android/receivers/alarm/NotificationBatchReceiver.java b/android/app/src/main/java/com/mindful/android/receivers/alarm/NotificationBatchReceiver.java index f370ef9..d5cfbab 100644 --- a/android/app/src/main/java/com/mindful/android/receivers/alarm/NotificationBatchReceiver.java +++ b/android/app/src/main/java/com/mindful/android/receivers/alarm/NotificationBatchReceiver.java @@ -24,7 +24,6 @@ import com.mindful.android.R; import com.mindful.android.helpers.AlarmTasksSchedulingHelper; import com.mindful.android.helpers.SharedPrefsHelper; -import com.mindful.android.utils.AppConstants; import com.mindful.android.utils.Utils; import org.json.JSONArray; @@ -56,7 +55,7 @@ public NotificationBatchWorker(@NonNull Context context, @NonNull WorkerParamete public Result doWork() { try { // Return if no available notifications - String jsonStr = SharedPrefsHelper.getUpComingNotificationsArrayString(mContext); + String jsonStr = SharedPrefsHelper.getSerializedNotificationsJson(mContext); int notificationsCount = new JSONArray(jsonStr).length(); if (notificationsCount == 0) return Result.success(); diff --git a/android/app/src/main/java/com/mindful/android/services/MindfulNotificationListenerService.java b/android/app/src/main/java/com/mindful/android/services/MindfulNotificationListenerService.java index 13764d2..5e117e1 100644 --- a/android/app/src/main/java/com/mindful/android/services/MindfulNotificationListenerService.java +++ b/android/app/src/main/java/com/mindful/android/services/MindfulNotificationListenerService.java @@ -21,24 +21,21 @@ import android.service.notification.StatusBarNotification; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import com.mindful.android.generics.ServiceBinder; import com.mindful.android.helpers.SharedPrefsHelper; import com.mindful.android.models.UpcomingNotification; import com.mindful.android.utils.Utils; -import org.jetbrains.annotations.Contract; - -import java.util.ArrayList; import java.util.HashSet; -import java.util.Map; +import java.util.Set; public class MindfulNotificationListenerService extends NotificationListenerService { private final String TAG = "Mindful.MindfulNotificationService"; private final ServiceBinder mBinder = new ServiceBinder<>(MindfulNotificationListenerService.this); + private final Set mSocialMediaPackages = Set.of("com.whatsapp", "com.instagram.android", "com.snapchat.android"); + private HashSet mDistractingApps = new HashSet<>(0); private boolean mIsListenerActive = false; @@ -46,14 +43,19 @@ public class MindfulNotificationListenerService extends NotificationListenerServ public void onListenerConnected() { super.onListenerConnected(); mDistractingApps = SharedPrefsHelper.getSetNotificationBatchedApps(this, null); + + // + SharedPrefsHelper.insertCrashLogToPrefs(this, new Throwable("MindfulNotificationListenerService is CONNECTED by system")); mIsListenerActive = true; } @Override public void onListenerDisconnected() { super.onListenerDisconnected(); - mIsListenerActive = false; + // + SharedPrefsHelper.insertCrashLogToPrefs(this, new Throwable("MindfulNotificationListenerService is DIS-CONNECTED by system")); + mIsListenerActive = false; } @Override @@ -62,21 +64,30 @@ public void onNotificationPosted(StatusBarNotification sbn) { if (!mIsListenerActive) return; String packageName = sbn.getPackageName(); try { - // Return if the posting app is not marked as distracting if (!mDistractingApps.contains(packageName) || !sbn.isClearable()) return; + // Dismiss notification cancelNotification(sbn.getKey()); Log.d(TAG, "onNotificationPosted: Notification dismissed"); - // Check if we need to store it or not - if (shouldStoreNotification(packageName, sbn.getTag())) { - UpcomingNotification notification = new UpcomingNotification(sbn); - SharedPrefsHelper.insertNotificationToPrefs(this, notification); - Log.d(TAG, "onNotificationPosted: Notification stored from package: " + packageName); + // Return if it is from social media but does not have tag + if (sbn.getTag() == null && mSocialMediaPackages.contains(packageName)) return; + + // Check if we can store it or not + UpcomingNotification notification = new UpcomingNotification(sbn); + if (notification.titleText.isEmpty() || notification.contentText.isEmpty()) { + Log.d(TAG, "onNotificationPosted: Notification is not valid, so skipping it from storing."); + SharedPrefsHelper.insertCrashLogToPrefs( + this, + new Exception("Invalid notification from " + packageName + " with title: " + notification.titleText + " and content: " + notification.contentText) + ); + return; } + SharedPrefsHelper.insertNotificationToPrefs(this, notification); + Log.d(TAG, "onNotificationPosted: Notification stored from package: " + packageName); } catch (Exception e) { SharedPrefsHelper.insertCrashLogToPrefs(this, e); Log.e(TAG, "onNotificationPosted: Something went wrong for package: " + packageName, e); @@ -84,23 +95,20 @@ public void onNotificationPosted(StatusBarNotification sbn) { } - @Contract(pure = true) - private boolean shouldStoreNotification(@NonNull String packageName, @Nullable String tag) { - // For whatsapp - if (packageName.equals("com.whatsapp") && tag == null) return false; - return true; - } - - public void updateDistractingApps(HashSet distractingApps) { mDistractingApps = distractingApps; Log.d(TAG, "updateDistractingApps: Distracting apps updated successfully"); } - @Override public IBinder onBind(Intent intent) { String action = Utils.getActionFromIntent(intent); return action.equals(ACTION_BIND_TO_MINDFUL) ? mBinder : super.onBind(intent); } + + @Override + public void onDestroy() { + super.onDestroy(); + SharedPrefsHelper.insertCrashLogToPrefs(this, new Throwable("MindfulNotificationListenerService is DESTROYED")); + } } \ No newline at end of file diff --git a/lib/core/services/method_channel_service.dart b/lib/core/services/method_channel_service.dart index 6be21ac..839fab0 100644 --- a/lib/core/services/method_channel_service.dart +++ b/lib/core/services/method_channel_service.dart @@ -99,10 +99,11 @@ class MethodChannelService { final logMap = Map.from(item); final log = CrashLogsTableCompanion( appVersion: Value(logMap['appVersion'] as String), - timeStamp: Value(DateTime.fromMillisecondsSinceEpoch( - logMap['timeStamp'] as int)), - error: Value(logMap['error'] as String), - stackTrace: Value(logMap['stackTrace'] as String), + timeStamp: Value( + DateTime.fromMillisecondsSinceEpoch(logMap['timeStamp'] as int), + ), + error: Value((logMap['error'] as String).trim()), + stackTrace: Value((logMap['stackTrace'] as String).trim()), ); crashLogs.add(log); @@ -148,6 +149,7 @@ class MethodChannelService { await _methodChannel.invokeMethod('getUpComingNotifications'); List notificationMapsList = jsonDecode(jsonString); + for (var item in notificationMapsList) { if (item is Map) { notifications diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d3ded72..027e062 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -403,6 +403,9 @@ "@----------------------------- UPCOMING NOTIFICATIONS SCREEN -----------------------------": {}, "upcoming_notifications_tab_title": "Notifications", "upcoming_notifications_empty_list_hint": "No notifications have been batched today.", + "button_segment_grouped_label": "Grouped", + "button_segment_ungrouped_label": "Un-Grouped", + "last_24_hours_heading": "Last 24 hours", "nNotifications": "{count, plural, =0{0 notification} =1{1 notification} other{{count} notifications}}", "@nNotifications": { "placeholders": { diff --git a/lib/providers/upcoming_notifications_provider.dart b/lib/providers/upcoming_notifications_provider.dart index 69809c4..0886b29 100644 --- a/lib/providers/upcoming_notifications_provider.dart +++ b/lib/providers/upcoming_notifications_provider.dart @@ -13,43 +13,75 @@ import 'package:mindful/core/services/method_channel_service.dart'; import 'package:mindful/models/notification_model.dart'; /// Holds map of Package and its list of notifications -final upcomingNotificationsProvider = StateNotifierProvider>>>( - (ref) => DeviceAppsList(), +final upcomingNotificationsProvider = StateNotifierProvider.family< + NotificationsNotifier, + AsyncValue>>, + bool>( + (ref, shouldGroup) => NotificationsNotifier(shouldGroup), ); -class DeviceAppsList +class NotificationsNotifier extends StateNotifier>>> { - DeviceAppsList() : super(const AsyncLoading()) { + NotificationsNotifier(this.groupConversations) : super(const AsyncLoading()) { refreshNotifications(); } + final bool groupConversations; + /// Fetches and updates the state with the latest list of pending notifications. - /// Future refreshNotifications() async { - Map> mapByPackages = {}; - final notifications = await MethodChannelService.instance.getUpComingNotifications(); + final Map> mapByPackages = {}; + + /// Group by package for (var notification in notifications) { - /// Get the list of notifications for the package if null then create one - List packageNotifications = - mapByPackages[notification.packageName] ?? []; - - /// Add current notification to the package notifications - packageNotifications.add(notification); - - /// update the map - mapByPackages.update( - notification.packageName, - (value) => packageNotifications, - ifAbsent: () => packageNotifications, - ); + mapByPackages + .putIfAbsent(notification.packageName, () => []) + .add(notification); } + /// sort notifications for each package. + mapByPackages.forEach((packageName, packageNotifications) { + mapByPackages[packageName] = + _groupAndSortNotifications(packageNotifications); + }); + + /// Sort packages by the most recent notification timestamp. + final sortedEntries = mapByPackages.entries.toList() + ..sort( + (a, b) => b.value.first.timeStamp.compareTo(a.value.first.timeStamp)); + /// update state - state = AsyncData(mapByPackages); + state = AsyncData(Map.fromEntries(sortedEntries)); return true; } + + /// Groups and sorts notifications within a package. + List _groupAndSortNotifications( + List notifications, + ) { + /// Merge notifications if they belongs to same conversation + if (groupConversations) { + final Map grouped = {}; + + for (var notification in notifications) { + grouped.update( + notification.titleText, + (existing) => existing.copyWith( + timeStamp: notification.timeStamp, + contentText: '${existing.contentText}\n${notification.contentText}', + ), + ifAbsent: () => notification, + ); + } + + notifications = grouped.values.toList(); + } + + /// Sort by timestamp (newest first). + notifications.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); + return notifications; + } } diff --git a/lib/ui/common/default_segmented_button.dart b/lib/ui/common/default_segmented_button.dart new file mode 100644 index 0000000..53482e4 --- /dev/null +++ b/lib/ui/common/default_segmented_button.dart @@ -0,0 +1,79 @@ +/* + * + * * Copyright (c) 2024 Mindful (https://github.com/akaMrNagar/Mindful) + * * Author : Pawan Nagar (https://github.com/akaMrNagar) + * * + * * This source code is licensed under the GPL-2.0 license license found in the + * * LICENSE file in the root directory of this source tree. + * + */ + +import 'package:flutter/material.dart'; + +class DefaultSegmentedButton extends StatelessWidget { + const DefaultSegmentedButton({ + super.key, + required this.selected, + required this.onChanged, + required this.segments, + }); + + final T selected; + final ValueChanged onChanged; + final List> segments; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + showSelectedIcon: false, + selected: {selected}, + onSelectionChanged: (set) => onChanged(set.first), + style: const ButtonStyle().copyWith( + visualDensity: VisualDensity.standard, + foregroundColor: WidgetStatePropertyAll( + Theme.of(context).iconTheme.color, + ), + padding: const WidgetStatePropertyAll(EdgeInsets.all(12)), + side: WidgetStatePropertyAll( + BorderSide( + color: Theme.of(context).colorScheme.secondaryContainer, + ), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + segments: segments + .map( + (e) => ButtonSegment( + icon: e.icon != null + ? Icon( + e.icon, + color: Theme.of(context).iconTheme.color, + ) + : null, + label: Text(e.label), + value: e.value, + ), + ) + .toList(), + ); + } +} + +class SegmentItem { + const SegmentItem({ + required this.value, + required this.label, + this.icon, + }); + + /// Value used to identify the segment. + final T value; + + /// Optional icon displayed in the segment. + final IconData? icon; + + /// Optional label displayed in the segment. + final String label; +} diff --git a/lib/ui/common/sliver_flexible_appbar.dart b/lib/ui/common/sliver_flexible_appbar.dart index 7b577a1..810f9f7 100644 --- a/lib/ui/common/sliver_flexible_appbar.dart +++ b/lib/ui/common/sliver_flexible_appbar.dart @@ -78,7 +78,8 @@ class SliverFlexibleAppBar extends ConsumerWidget { Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (useBottomNavigation) materialBarLeading ?? 0.vBox, + 6.hBox, + if (useBottomNavigation) materialBarLeading ?? 0.hBox, Expanded( child: StyledText( title, diff --git a/lib/ui/common/sliver_usage_cards.dart b/lib/ui/common/sliver_usage_cards.dart index 14a12df..b1cb88e 100644 --- a/lib/ui/common/sliver_usage_cards.dart +++ b/lib/ui/common/sliver_usage_cards.dart @@ -17,7 +17,9 @@ import 'package:mindful/core/extensions/ext_build_context.dart'; import 'package:mindful/core/extensions/ext_duration.dart'; import 'package:mindful/core/extensions/ext_int.dart'; import 'package:mindful/core/extensions/ext_num.dart'; +import 'package:mindful/core/extensions/ext_widget.dart'; import 'package:mindful/ui/common/default_list_tile.dart'; +import 'package:mindful/ui/common/default_segmented_button.dart'; import 'package:mindful/ui/common/styled_text.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -44,48 +46,22 @@ class SliverUsageCards extends StatelessWidget { return SliverList.list( children: [ /// Usage type selector - - Align( - alignment: Alignment.centerLeft, - child: SegmentedButton( - showSelectedIcon: false, - selected: {usageType}, - onSelectionChanged: (set) => onUsageTypeChanged(set.first), - style: const ButtonStyle().copyWith( - visualDensity: VisualDensity.standard, - foregroundColor: WidgetStatePropertyAll( - Theme.of(context).iconTheme.color, - ), - padding: const WidgetStatePropertyAll(EdgeInsets.all(12)), - side: WidgetStatePropertyAll( - BorderSide( - color: Theme.of(context).colorScheme.secondaryContainer, - ), - ), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), + DefaultSegmentedButton( + selected: usageType, + onChanged: (value) => onUsageTypeChanged(value), + segments: [ + SegmentItem( + icon: FluentIcons.phone_screen_time_20_regular, + label: context.locale.screen_segment_label, + value: UsageType.screenUsage, ), - segments: [ - ButtonSegment( - icon: Icon( - FluentIcons.phone_screen_time_20_regular, - color: Theme.of(context).iconTheme.color, - ), - label: Text(context.locale.screen_segment_label), - value: UsageType.screenUsage, - ), - ButtonSegment( - icon: Icon( - FluentIcons.earth_20_regular, - color: Theme.of(context).iconTheme.color, - ), - label: Text(context.locale.data_segment_label), - value: UsageType.networkUsage, - ), - ], - ), - ), + SegmentItem( + icon: FluentIcons.earth_20_regular, + label: context.locale.data_segment_label, + value: UsageType.networkUsage, + ), + ], + ).leftCentered, /// Usage info cards SizedBox( diff --git a/lib/ui/common/status_label.dart b/lib/ui/common/status_label.dart new file mode 100644 index 0000000..84c2440 --- /dev/null +++ b/lib/ui/common/status_label.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:mindful/core/extensions/ext_num.dart'; +import 'package:mindful/ui/common/rounded_container.dart'; +import 'package:mindful/ui/common/styled_text.dart'; + +class StatusLabel extends StatelessWidget { + const StatusLabel({ + super.key, + required this.label, + this.accent, + }); + + final String label; + final Color? accent; + + @override + Widget build(BuildContext context) { + final accentColor = accent ?? Theme.of(context).colorScheme.primary; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + RoundedContainer( + circularRadius: 8, + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + color: accentColor.withOpacity(0.15), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + RoundedContainer( + height: 10, + width: 10, + color: accentColor, + ), + 8.hBox, + StyledText( + label, + fontWeight: FontWeight.w500, + color: accentColor, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/ui/screens/batch_notifications/batch_notifications_screen.dart b/lib/ui/screens/batch_notifications/batch_notifications_screen.dart index d4cb249..8cd5df0 100644 --- a/lib/ui/screens/batch_notifications/batch_notifications_screen.dart +++ b/lib/ui/screens/batch_notifications/batch_notifications_screen.dart @@ -41,7 +41,7 @@ class BatchNotificationsScreen extends ConsumerWidget { final batchedAppsCount = ref.watch(sharedUniqueDataProvider .select((v) => v.notificationBatchedApps.length)); - final upcomingNotificationsCount = ref.watch(upcomingNotificationsProvider + final upcomingNotificationsCount = ref.watch(upcomingNotificationsProvider(false) .select((v) => v.value?.values.fold(0, (v, e) => v + e.length))) ?? 0; @@ -61,7 +61,7 @@ class BatchNotificationsScreen extends ConsumerWidget { fab: havePermission ? const NewNotificationScheduleFab() : null, sliverBody: DefaultRefreshIndicator( onRefresh: ref - .read(upcomingNotificationsProvider.notifier) + .read(upcomingNotificationsProvider(false).notifier) .refreshNotifications, child: CustomScrollView( physics: const BouncingScrollPhysics(), diff --git a/lib/ui/screens/focus/timeline/session_card.dart b/lib/ui/screens/focus/timeline/session_card.dart index b99a711..c1dfd04 100644 --- a/lib/ui/screens/focus/timeline/session_card.dart +++ b/lib/ui/screens/focus/timeline/session_card.dart @@ -20,6 +20,7 @@ import 'package:mindful/core/extensions/ext_duration.dart'; import 'package:mindful/core/extensions/ext_num.dart'; import 'package:mindful/core/utils/utils.dart'; import 'package:mindful/ui/common/rounded_container.dart'; +import 'package:mindful/ui/common/status_label.dart'; import 'package:mindful/ui/common/styled_text.dart'; import 'package:mindful/ui/transitions/default_effects.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -110,23 +111,9 @@ class SessionCard extends StatelessWidget { /// State Label Skeleton.leaf( - child: RoundedContainer( - circularRadius: 8, - width: 108, - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - color: stateColor.withOpacity(0.15), - child: Row( - children: [ - RoundedContainer( - height: 10, - width: 10, - color: stateColor, - ), - 8.hBox, - StyledText(stateLabel, color: stateColor), - ], - ), + child: StatusLabel( + label: stateLabel, + accent: stateColor, ), ), ], diff --git a/lib/ui/screens/upcoming_notifications/notifications_group_tile.dart b/lib/ui/screens/upcoming_notifications/app_notifications_tile.dart similarity index 77% rename from lib/ui/screens/upcoming_notifications/notifications_group_tile.dart rename to lib/ui/screens/upcoming_notifications/app_notifications_tile.dart index c0dfa32..1e4f1d5 100644 --- a/lib/ui/screens/upcoming_notifications/notifications_group_tile.dart +++ b/lib/ui/screens/upcoming_notifications/app_notifications_tile.dart @@ -20,10 +20,11 @@ import 'package:mindful/providers/apps_provider.dart'; import 'package:mindful/ui/common/application_icon.dart'; import 'package:mindful/ui/common/default_expandable_list_tile.dart'; import 'package:mindful/ui/common/default_list_tile.dart'; +import 'package:mindful/ui/common/status_label.dart'; import 'package:mindful/ui/common/styled_text.dart'; -class NotificationsGroupTile extends ConsumerWidget { - const NotificationsGroupTile({ +class AppsNotificationsTile extends ConsumerWidget { + const AppsNotificationsTile({ super.key, required this.packageName, required this.notifications, @@ -37,23 +38,22 @@ class NotificationsGroupTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final app = ref.watch(appsProvider.select((v) => v.value?[packageName])); - return app == null ? 0.vBox : DefaultExpandableListTile( titleText: app.name, + position: position, subtitle: StyledText( context.locale.nNotifications(notifications.length), fontSize: 14, color: Theme.of(context).hintColor, ), leading: ApplicationIcon(app: app), - position: position, content: ListView.builder( shrinkWrap: true, primary: false, + padding: const EdgeInsets.all(0), itemCount: notifications.length, - padding: const EdgeInsets.symmetric(), itemBuilder: (context, index) => _Notification( title: notifications[index].titleText, content: notifications[index].contentText, @@ -81,27 +81,50 @@ class _Notification extends StatelessWidget { @override Widget build(BuildContext context) { + final isOlder = timeStamp.isBefore(DateTime.now().dateOnly); + return DefaultListTile( position: ItemPosition.mid, onPressed: onPressed, title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + /// Title Expanded( child: StyledText( title, fontSize: 16, + maxLines: 2, overflow: TextOverflow.ellipsis, ), ), 4.hBox, - StyledText(timeStamp.timeString(context).toLowerCase()), + + /// Timestamp + StyledText( + timeStamp.timeString(context).toLowerCase(), + ), ], ), - subtitle: StyledText( - content, - fontSize: 14, - color: Theme.of(context).hintColor, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 8.vBox, + + /// Conversation + StyledText( + content, + fontSize: 14, + color: Theme.of(context).hintColor, + ), + + /// Older label + if (isOlder) + Padding( + padding: const EdgeInsets.only(top: 12), + child: StatusLabel(label: context.locale.day_yesterday), + ), + ], ), ); } diff --git a/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart b/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart index 78b67ca..7e0ab62 100644 --- a/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart +++ b/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart @@ -13,22 +13,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mindful/core/extensions/ext_build_context.dart'; import 'package:mindful/core/extensions/ext_widget.dart'; -import 'package:mindful/core/utils/utils.dart'; import 'package:mindful/providers/upcoming_notifications_provider.dart'; +import 'package:mindful/ui/common/content_section_header.dart'; import 'package:mindful/ui/common/default_refresh_indicator.dart'; import 'package:mindful/ui/common/default_scaffold.dart'; +import 'package:mindful/ui/common/default_segmented_button.dart'; +import 'package:mindful/ui/common/sliver_implicitly_animated_list.dart'; import 'package:mindful/ui/common/sliver_shimmer_list.dart'; import 'package:mindful/ui/common/sliver_tabs_bottom_padding.dart'; import 'package:mindful/ui/common/styled_text.dart'; -import 'package:mindful/ui/screens/upcoming_notifications/notifications_group_tile.dart'; +import 'package:mindful/ui/screens/upcoming_notifications/app_notifications_tile.dart'; -class UpcomingNotificationsScreen extends ConsumerWidget { +class UpcomingNotificationsScreen extends ConsumerStatefulWidget { const UpcomingNotificationsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final notificationMap = ref.watch(upcomingNotificationsProvider); - final notificationEntries = notificationMap.value?.entries.toList() ?? []; + ConsumerState createState() => + _UpcomingStateNotificationsScreen(); +} + +class _UpcomingStateNotificationsScreen + extends ConsumerState { + bool _shouldGroup = true; + + @override + Widget build(BuildContext context) { + final notificationMap = + ref.watch(upcomingNotificationsProvider(_shouldGroup)); + + /// {App Package : List of notifications} + final notificationsByApp = notificationMap.value?.entries.toList() ?? []; return DefaultScaffold( navbarItems: [ @@ -38,13 +52,34 @@ class UpcomingNotificationsScreen extends ConsumerWidget { title: context.locale.upcoming_notifications_tab_title, sliverBody: DefaultRefreshIndicator( onRefresh: ref - .read(upcomingNotificationsProvider.notifier) + .read(upcomingNotificationsProvider(_shouldGroup).notifier) .refreshNotifications, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ + /// Group un-group conversation + DefaultSegmentedButton( + selected: _shouldGroup, + onChanged: (value) => setState(() => _shouldGroup = value), + segments: [ + SegmentItem( + label: context.locale.button_segment_grouped_label, + value: true, + ), + SegmentItem( + label: context.locale.button_segment_ungrouped_label, + value: false, + ), + ], + ).leftCentered.sliver, + + ContentSectionHeader( + title: context.locale.last_24_hours_heading, + ).sliver, + + /// Notifications notificationMap.hasValue - ? notificationEntries.isEmpty + ? notificationsByApp.isEmpty /// No notifications ? SizedBox( @@ -67,16 +102,14 @@ class UpcomingNotificationsScreen extends ConsumerWidget { ).sliver /// Have notifications - : SliverList.builder( - itemCount: notificationEntries.length, - itemBuilder: (context, index) => - NotificationsGroupTile( - packageName: notificationEntries[index].key, - notifications: notificationEntries[index].value, - position: getItemPositionInList( - index, - notificationEntries.length, - ), + : SliverImplicitlyAnimatedList( + items: notificationsByApp, + keyBuilder: (entry) => entry.key, + itemBuilder: (context, entry, position) => + AppsNotificationsTile( + packageName: entry.key, + notifications: entry.value, + position: position, ), ) From 1569368613401c03fbe55719dc39255333bc7149 Mon Sep 17 00:00:00 2001 From: Pawan Nagar Date: Thu, 2 Jan 2025 02:37:58 +0530 Subject: [PATCH 2/5] Now user can view crash logs in app --- lib/ui/common/default_slide_to_remove.dart | 1 - lib/ui/dialogs/modal_bottom_sheet.dart | 61 +++++++++-------- .../database/export_clear_crash_logs.dart | 18 +++++ .../database/sliver_crash_logs_list.dart | 68 +++++++++++++++++++ 4 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 lib/ui/screens/settings/database/sliver_crash_logs_list.dart diff --git a/lib/ui/common/default_slide_to_remove.dart b/lib/ui/common/default_slide_to_remove.dart index 0d437ca..39b2214 100644 --- a/lib/ui/common/default_slide_to_remove.dart +++ b/lib/ui/common/default_slide_to_remove.dart @@ -28,7 +28,6 @@ class DefaultSlideToRemove extends StatelessWidget { key: key, endActionPane: ActionPane( motion: const DrawerMotion(), - dismissible: DismissiblePane(onDismissed: onDismiss), children: [ SlidableAction( autoClose: true, diff --git a/lib/ui/dialogs/modal_bottom_sheet.dart b/lib/ui/dialogs/modal_bottom_sheet.dart index a19d486..7ec6469 100644 --- a/lib/ui/dialogs/modal_bottom_sheet.dart +++ b/lib/ui/dialogs/modal_bottom_sheet.dart @@ -10,47 +10,48 @@ import 'package:flutter/material.dart'; import 'package:mindful/core/extensions/ext_num.dart'; -import 'package:mindful/ui/common/rounded_container.dart'; +import 'package:mindful/ui/common/content_section_header.dart'; +import 'package:mindful/ui/common/sliver_tabs_bottom_padding.dart'; /// Opens modal bottom sheet with the passed sliver body Future showDefaultBottomSheet({ required BuildContext context, required Widget sliverBody, - double heightFactor = 0.85, EdgeInsets padding = const EdgeInsets.symmetric(horizontal: 12), + Widget? header, + String? headerTitle, }) async => showModalBottomSheet( context: context, isScrollControlled: true, - builder: (sheetContext) => BottomSheet( - enableDrag: false, - onClosing: () {}, - builder: (context) => FractionallySizedBox( - heightFactor: heightFactor, - child: Padding( - padding: padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - /// Handle - RoundedContainer( - height: 8, - width: 48, - margin: const EdgeInsets.only(top: 12, bottom: 20), - color: Theme.of(context).hintColor, - ), + useSafeArea: true, + showDragHandle: true, + builder: (sheetContext) => DraggableScrollableSheet( + expand: false, + maxChildSize: 0.99, // Maximum height factor (1.0) + builder: (context, scrollController) => Padding( + padding: padding, + child: Column( + children: [ + /// Header + headerTitle != null + ? ContentSectionHeader( + title: headerTitle, + padding: const EdgeInsets.only(bottom: 12), + ) + : header ?? 0.vBox, - /// Body - Expanded( - child: CustomScrollView( - slivers: [ - sliverBody, - 96.vSliverBox, - ], - ), - ) - ], - ), + /// Body + Expanded( + child: CustomScrollView( + controller: scrollController, + slivers: [ + sliverBody, + const SliverTabsBottomPadding(), + ], + ), + ), + ], ), ), ), diff --git a/lib/ui/screens/settings/database/export_clear_crash_logs.dart b/lib/ui/screens/settings/database/export_clear_crash_logs.dart index 500ab10..5517901 100644 --- a/lib/ui/screens/settings/database/export_clear_crash_logs.dart +++ b/lib/ui/screens/settings/database/export_clear_crash_logs.dart @@ -20,12 +20,15 @@ import 'package:mindful/core/extensions/ext_build_context.dart'; import 'package:mindful/core/extensions/ext_num.dart'; import 'package:mindful/core/extensions/ext_widget.dart'; import 'package:mindful/core/services/drift_db_service.dart'; +import 'package:mindful/core/services/method_channel_service.dart'; import 'package:mindful/core/utils/hero_tags.dart'; import 'package:mindful/providers/device_info_provider.dart'; import 'package:mindful/ui/common/content_section_header.dart'; import 'package:mindful/ui/common/default_list_tile.dart'; import 'package:mindful/ui/common/styled_text.dart'; import 'package:mindful/ui/dialogs/confirmation_dialog.dart'; +import 'package:mindful/ui/dialogs/modal_bottom_sheet.dart'; +import 'package:mindful/ui/screens/settings/database/sliver_crash_logs_list.dart'; import 'package:mindful/ui/transitions/default_hero.dart'; import 'package:sliver_tools/sliver_tools.dart'; @@ -63,6 +66,20 @@ class _ExportClearCrashLogsState extends ConsumerState { onPressed: _shareLogs, ).sliver, + /// view + DefaultListTile( + position: ItemPosition.mid, + titleText: context.locale.crash_logs_view_tile_title, + subtitleText: context.locale.crash_logs_view_tile_subtitle, + leadingIcon: FluentIcons.notepad_20_regular, + trailing: const Icon(FluentIcons.chevron_right_20_regular), + onPressed: () => showDefaultBottomSheet( + context: context, + headerTitle: context.locale.crash_logs_heading, + sliverBody: const SliverCrashLogsList(), + ), + ).sliver, + /// Clear DefaultHero( tag: HeroTags.clearCrashLogsTileTag, @@ -130,6 +147,7 @@ class _ExportClearCrashLogsState extends ConsumerState { ); if (confirm) { + await MethodChannelService.instance.clearNativeCrashLogs(); await DriftDbService.instance.driftDb.dynamicRecordsDao.clearCrashLogs(); } } diff --git a/lib/ui/screens/settings/database/sliver_crash_logs_list.dart b/lib/ui/screens/settings/database/sliver_crash_logs_list.dart new file mode 100644 index 0000000..80147cf --- /dev/null +++ b/lib/ui/screens/settings/database/sliver_crash_logs_list.dart @@ -0,0 +1,68 @@ +/* + * + * * Copyright (c) 2024 Mindful (https://github.com/akaMrNagar/Mindful) + * * Author : Pawan Nagar (https://github.com/akaMrNagar) + * * + * * This source code is licensed under the GPL-2.0 license license found in the + * * LICENSE file in the root directory of this source tree. + * + */ + +import 'package:flutter/material.dart'; +import 'package:mindful/core/database/app_database.dart'; +import 'package:mindful/core/enums/item_position.dart'; +import 'package:mindful/core/extensions/ext_date_time.dart'; +import 'package:mindful/core/extensions/ext_num.dart'; +import 'package:mindful/core/services/drift_db_service.dart'; +import 'package:mindful/core/utils/utils.dart'; +import 'package:mindful/ui/common/default_expandable_list_tile.dart'; +import 'package:mindful/ui/common/rounded_container.dart'; +import 'package:mindful/ui/common/sliver_shimmer_list.dart'; +import 'package:mindful/ui/common/status_label.dart'; +import 'package:mindful/ui/common/styled_text.dart'; + +class SliverCrashLogsList extends StatelessWidget { + const SliverCrashLogsList({super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: + DriftDbService.instance.driftDb.dynamicRecordsDao.fetchCrashLogs(), + builder: (context, data) => data.hasData + ? SliverList.builder( + itemCount: data.data?.length ?? 0, + itemBuilder: (context, index) => DefaultExpandableListTile( + position: getItemPositionInList( + index, + data.data?.length ?? 0, + ), + titleText: data.data?[index].timeStamp.dateTimeString(context), + subtitleText: data.data?[index].error.trim(), + content: RoundedContainer( + borderRadius: getBorderRadiusFromPosition(ItemPosition.mid), + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Version label + StatusLabel( + label: data.data?[index].appVersion ?? "", + ), + + 12.vBox, + + /// Stacktrace + StyledText( + "${data.data?[index].stackTrace.trim()}", + ), + ], + ), + ), + ), + ) + : const SliverShimmerList(includeSubtitle: true), + ); + } +} From d82df4538112b5977f747252681830130c79cb64 Mon Sep 17 00:00:00 2001 From: Pawan Nagar Date: Fri, 3 Jan 2025 18:21:00 +0530 Subject: [PATCH 3/5] Moved distracting apps list to modal sheet --- .../android/helpers/SharedPrefsHelper.java | 42 ++++++++++--------- .../database/daos/dynamic_records_dao.dart | 6 ++- lib/core/extensions/ext_date_time.dart | 6 +++ lib/ui/common/search_filter_panel.dart | 8 +++- .../common/sliver_distracting_apps_list.dart | 31 +++++++++----- lib/ui/dialogs/modal_bottom_sheet.dart | 10 ++++- .../create_update_group_screen.dart | 2 + .../upcoming_notifications_screen.dart | 22 +++++----- 8 files changed, 81 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/java/com/mindful/android/helpers/SharedPrefsHelper.java b/android/app/src/main/java/com/mindful/android/helpers/SharedPrefsHelper.java index c05f94b..dd309ac 100644 --- a/android/app/src/main/java/com/mindful/android/helpers/SharedPrefsHelper.java +++ b/android/app/src/main/java/com/mindful/android/helpers/SharedPrefsHelper.java @@ -31,9 +31,12 @@ import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; /** * Helper class to manage SharedPreferences operations. @@ -352,6 +355,23 @@ public static HashSet getSetNotificationBatchSchedules(@NonNull Context } } + /** + * Fetches the hashset of notification batched apps if jsonBatchedApps is null else store it's json. + * + * @param context The application context. + * @param jsonBatchedApps The JSON string of notification batched apps. + */ + @NonNull + public static HashSet getSetNotificationBatchedApps(@NonNull Context context, @Nullable String jsonBatchedApps) { + checkAndInitializeNotificationBatchPrefs(context); + if (jsonBatchedApps == null) { + return JsonDeserializer.jsonStrToStringHashSet(mNotificationBatchPrefs.getString(PREF_KEY_BATCHED_APPS, "")); + } else { + mNotificationBatchPrefs.edit().putString(PREF_KEY_BATCHED_APPS, jsonBatchedApps).apply(); + return JsonDeserializer.jsonStrToStringHashSet(jsonBatchedApps); + } + } + /** * Creates and Inserts a new notification into SharedPreferences based on the passed object. * @@ -361,7 +381,7 @@ public static HashSet getSetNotificationBatchSchedules(@NonNull Context public static void insertNotificationToPrefs(@NonNull Context context, @NonNull UpcomingNotification notification) { checkAndInitializeNotificationBatchPrefs(context); - // Create new notification object + // Create new json object JSONObject currentNotification = new JSONObject(notification.toMap()); // Get existing notifications @@ -379,7 +399,6 @@ public static void insertNotificationToPrefs(@NonNull Context context, @NonNull notificationsArray.remove(i); } } - } catch (Exception e1) { notificationsArray = new JSONArray(); } @@ -397,26 +416,9 @@ public static void insertNotificationToPrefs(@NonNull Context context, @NonNull * @return A JSON string representing the stored notifications array. */ @NonNull - public static String getUpComingNotificationsArrayString(@NonNull Context context) { + public static String getSerializedNotificationsJson(@NonNull Context context) { checkAndInitializeNotificationBatchPrefs(context); return mNotificationBatchPrefs.getString(PREF_KEY_UPCOMING_NOTIFICATIONS, "[]"); } - /** - * Fetches the hashset of notification batched apps if jsonBatchedApps is null else store it's json. - * - * @param context The application context. - * @param jsonBatchedApps The JSON string of notification batched apps. - */ - @NonNull - public static HashSet getSetNotificationBatchedApps(@NonNull Context context, @Nullable String jsonBatchedApps) { - checkAndInitializeNotificationBatchPrefs(context); - if (jsonBatchedApps == null) { - return JsonDeserializer.jsonStrToStringHashSet(mNotificationBatchPrefs.getString(PREF_KEY_BATCHED_APPS, "")); - } else { - mNotificationBatchPrefs.edit().putString(PREF_KEY_BATCHED_APPS, jsonBatchedApps).apply(); - return JsonDeserializer.jsonStrToStringHashSet(jsonBatchedApps); - } - } - } diff --git a/lib/core/database/daos/dynamic_records_dao.dart b/lib/core/database/daos/dynamic_records_dao.dart index d1305c2..dcab303 100644 --- a/lib/core/database/daos/dynamic_records_dao.dart +++ b/lib/core/database/daos/dynamic_records_dao.dart @@ -140,7 +140,9 @@ class DynamicRecordsDao extends DatabaseAccessor .go(); /// Loads list of all [CrashLog] objects from the database, - Future> fetchCrashLogs() async => select(crashLogsTable).get(); + Future> fetchCrashLogs() async => ((select(crashLogsTable)) + ..orderBy([(e) => OrderingTerm.desc(e.timeStamp)])) + .get(); /// Clear all [CrashLogs] objects from the database, Future clearCrashLogs() async => delete(crashLogsTable).go(); @@ -254,7 +256,7 @@ class DynamicRecordsDao extends DatabaseAccessor ..where( (e) => e.startDateTime.isBetweenValues(start, end), ) - ..orderBy([(tbl) => OrderingTerm.desc(tbl.startDateTime)])) + ..orderBy([(e) => OrderingTerm.desc(e.startDateTime)])) .get(); /// Loads the total duration in seconds for all the [FocusSession] in the database for the provided interval diff --git a/lib/core/extensions/ext_date_time.dart b/lib/core/extensions/ext_date_time.dart index a105722..a300e5d 100644 --- a/lib/core/extensions/ext_date_time.dart +++ b/lib/core/extensions/ext_date_time.dart @@ -41,6 +41,12 @@ extension ExtDateTime on DateTime { String timeString(BuildContext context) => DateFormat.jm(Localizations.localeOf(context).languageCode).format(this); + /// Returns date and time string in a localized format (e.g., 1 Jan 2025, 7:15 PM). + String dateTimeString(BuildContext context) => DateFormat( + 'd MMM yyyy, h:mm a', + Localizations.localeOf(context).languageCode, + ).format(this); + /// Returns TRUE if the [DateTime] lies between [start] and [end] else false. bool isBetween(DateTime start, DateTime end) { return isAfter(start) && isBefore(end); diff --git a/lib/ui/common/search_filter_panel.dart b/lib/ui/common/search_filter_panel.dart index 32616f5..688fede 100644 --- a/lib/ui/common/search_filter_panel.dart +++ b/lib/ui/common/search_filter_panel.dart @@ -45,14 +45,15 @@ class _SearchFilterPanelState extends State { @override void dispose() { - _debouncer?.cancel(); super.dispose(); + _debouncer?.cancel(); } @override Widget build(BuildContext context) { return Row( children: [ + /// Search bar Expanded( child: DefaultListTile( color: Theme.of(context).colorScheme.secondaryContainer, @@ -63,9 +64,12 @@ class _SearchFilterPanelState extends State { ), onChanged: _onQueryChanged, onSubmitted: _onQuerySubmitted, + onTapOutside: (_) => FocusScope.of(context).unfocus(), ), ), ), + + /// Sort by screen time, network usage, alphabetically RoundedContainer( color: Theme.of(context).colorScheme.secondaryContainer, padding: const EdgeInsets.all(14), @@ -86,6 +90,8 @@ class _SearchFilterPanelState extends State { ), ), ), + + /// Ascending - Descending RoundedContainer( color: Theme.of(context).colorScheme.secondaryContainer, padding: const EdgeInsets.all(14), diff --git a/lib/ui/common/sliver_distracting_apps_list.dart b/lib/ui/common/sliver_distracting_apps_list.dart index efcb676..1d19b24 100644 --- a/lib/ui/common/sliver_distracting_apps_list.dart +++ b/lib/ui/common/sliver_distracting_apps_list.dart @@ -13,7 +13,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mindful/core/enums/item_position.dart'; import 'package:mindful/core/extensions/ext_build_context.dart'; import 'package:mindful/core/extensions/ext_num.dart'; -import 'package:mindful/core/extensions/ext_widget.dart'; import 'package:mindful/core/utils/app_constants.dart'; import 'package:mindful/core/utils/utils.dart'; import 'package:mindful/models/filter_model.dart'; @@ -33,11 +32,13 @@ class SliverDistractingAppsList extends ConsumerStatefulWidget { required this.distractingApps, required this.onSelectionChanged, this.hiddenApps = const [], + this.isInsideModalSheet = true, }); final List distractingApps; final List hiddenApps; final Function(String package, bool isSelected) onSelectionChanged; + final bool isInsideModalSheet; @override ConsumerState createState() => @@ -69,19 +70,29 @@ class _SliverDistractingAppsListState return MultiSliver( children: [ + /// Search and filter panel + widget.isInsideModalSheet + ? PinnedHeaderSliver( + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.only(bottom: 8), + child: SearchFilterPanel( + filter: _filter, + onFilterChanged: _onFilterChanged, + ), + ), + ) + : SearchFilterPanel( + filter: _filter, + onFilterChanged: _onFilterChanged, + ), + + /// Header ContentSectionHeader( title: widget.distractingApps.isEmpty ? context.locale.select_distracting_apps_heading : context.locale.your_distracting_apps_heading, - ).sliver, - - /// Search and filter panel - SearchFilterPanel( - filter: _filter, - onFilterChanged: _onFilterChanged, - ).sliver, - - 18.vSliverBox, + ), /// Apps list SliverAnimatedSwitcher( diff --git a/lib/ui/dialogs/modal_bottom_sheet.dart b/lib/ui/dialogs/modal_bottom_sheet.dart index 7ec6469..2b7f2db 100644 --- a/lib/ui/dialogs/modal_bottom_sheet.dart +++ b/lib/ui/dialogs/modal_bottom_sheet.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:mindful/core/extensions/ext_num.dart'; +import 'package:mindful/core/utils/app_constants.dart'; import 'package:mindful/ui/common/content_section_header.dart'; import 'package:mindful/ui/common/sliver_tabs_bottom_padding.dart'; @@ -26,14 +27,19 @@ Future showDefaultBottomSheet({ isScrollControlled: true, useSafeArea: true, showDragHandle: true, + sheetAnimationStyle: AnimationStyle( + duration: AppConstants.defaultAnimDuration, + curve: AppConstants.defaultCurve, + reverseDuration: AppConstants.defaultAnimDuration, + reverseCurve: AppConstants.defaultCurve.flipped, + ), builder: (sheetContext) => DraggableScrollableSheet( expand: false, - maxChildSize: 0.99, // Maximum height factor (1.0) builder: (context, scrollController) => Padding( padding: padding, child: Column( children: [ - /// Header + /// Header2 headerTitle != null ? ContentSectionHeader( title: headerTitle, diff --git a/lib/ui/screens/restriction_groups/create_update_group_screen.dart b/lib/ui/screens/restriction_groups/create_update_group_screen.dart index f27b205..6fa7725 100644 --- a/lib/ui/screens/restriction_groups/create_update_group_screen.dart +++ b/lib/ui/screens/restriction_groups/create_update_group_screen.dart @@ -256,7 +256,9 @@ class _CreateUpdateRestrictionGroupState ).sliver, /// Distracting apps + 36.vSliverBox, SliverDistractingAppsList( + isInsideModalSheet: false, distractingApps: _group.distractingApps, hiddenApps: alreadyGroupedApps, onSelectionChanged: (package, isSelected) { diff --git a/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart b/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart index 7e0ab62..eded034 100644 --- a/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart +++ b/lib/ui/screens/upcoming_notifications/upcoming_notifications_screen.dart @@ -78,12 +78,18 @@ class _UpcomingStateNotificationsScreen ).sliver, /// Notifications - notificationMap.hasValue - ? notificationsByApp.isEmpty + notificationMap.isLoading - /// No notifications + /// Loading notifications + ? const SliverShimmerList( + includeSubtitle: true, + includeTrailing: true, + ) + + /// No notifications + : notificationsByApp.isEmpty ? SizedBox( - height: 256, + height: MediaQuery.of(context).size.height * 0.5, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -111,13 +117,7 @@ class _UpcomingStateNotificationsScreen notifications: entry.value, position: position, ), - ) - - /// Loading notifications - : const SliverShimmerList( - includeSubtitle: true, - includeTrailing: true, - ), + ), /// padding const SliverTabsBottomPadding(), From 3ebcbd0b70f9931875a57695ad12e3b476c8a9a7 Mon Sep 17 00:00:00 2001 From: Pawan Nagar Date: Fri, 3 Jan 2025 21:00:05 +0530 Subject: [PATCH 4/5] Added routing serive to handle route during app start using intent --- android/app/build.gradle | 32 +++++++------ .../com/mindful/android/MainActivity.java | 6 +-- .../mindful/android/utils/AppConstants.java | 2 +- .../app/src/main/res/values-ja/strings.xml | 1 - android/app/src/main/res/values/strings.xml | 1 - lib/core/services/routing_service.dart | 22 ++++++--- lib/l10n/app_en.arb | 2 + lib/models/intent_data.dart | 27 ++++++----- lib/providers/bedtime_provider.dart | 2 +- lib/providers/mindful_settings_provider.dart | 2 +- .../notification_schedules_provider.dart | 2 +- .../shared_unique_data_provider.dart | 2 +- lib/providers/wellbeing_provider.dart | 2 +- lib/ui/common/application_icon.dart | 47 +++++++++---------- lib/ui/onboarding/onboarding_screen.dart | 2 +- lib/ui/screens/home/home_screen.dart | 2 +- 16 files changed, 83 insertions(+), 71 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1b8a46e..76ee8be 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -45,34 +45,36 @@ android { } + signingConfigs { + release { + if (System.getenv("KEYSTORE_FILE") != null) { + storeFile file(System.getenv("KEYSTORE_FILE")) + storePassword System.getenv("STORE_PASSWORD") + keyAlias System.getenv("KEY_ALIAS") + keyPassword System.getenv("KEY_PASSWORD") + } + } + } + buildTypes { release { ndk { debugSymbolLevel 'full' } - if (System.getenv("KEYSTORE_FILE") != null) { - signingConfigs { - register("release") { - storeFile file(System.getenv("KEYSTORE_FILE")) - storePassword System.getenv("STORE_PASSWORD") - keyAlias System.getenv("KEY_ALIAS") - keyPassword System.getenv("KEY_PASSWORD") - } - } - signingConfig = signingConfigs.release - } else { - signingConfig = signingConfigs.debug - } + resValue "string", "app_name", "Mindful" + signingConfig = signingConfigs.release } debug { applicationIdSuffix ".debug" - signingConfig signingConfigs.debug + resValue "string", "app_name", "Mindful Debug" + signingConfig = signingConfigs.release ?: signingConfigs.debug } profile { applicationIdSuffix ".profile" - signingConfig signingConfigs.debug + resValue "string", "app_name", "Mindful Profile" + signingConfig = signingConfigs.release ?: signingConfigs.debug } } buildFeatures { diff --git a/android/app/src/main/java/com/mindful/android/MainActivity.java b/android/app/src/main/java/com/mindful/android/MainActivity.java index 970b6e1..38f2c26 100644 --- a/android/app/src/main/java/com/mindful/android/MainActivity.java +++ b/android/app/src/main/java/com/mindful/android/MainActivity.java @@ -121,8 +121,8 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { Intent currentIntent = getIntent(); Map intentData = new HashMap<>(); intentData.put("route", currentIntent.getStringExtra(INTENT_EXTRA_INITIAL_ROUTE)); - intentData.put("targetedPackage", currentIntent.getStringExtra(INTENT_EXTRA_PACKAGE_NAME)); - intentData.put("isSelfRestart", currentIntent.getBooleanExtra(INTENT_EXTRA_IS_SELF_RESTART, false)); + intentData.put("extraPackageName", currentIntent.getStringExtra(INTENT_EXTRA_PACKAGE_NAME)); + intentData.put("extraIsSelfStart", currentIntent.getBooleanExtra(INTENT_EXTRA_IS_SELF_RESTART, false)); // Update intent data on flutter side mMethodChannel.invokeMethod("updateIntentData", intentData); @@ -160,7 +160,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result break; } case "getUpComingNotifications": { - result.success(SharedPrefsHelper.getUpComingNotificationsArrayString(this)); + result.success(SharedPrefsHelper.getSerializedNotificationsJson(this)); break; } case "getShortsScreenTimeMs": { diff --git a/android/app/src/main/java/com/mindful/android/utils/AppConstants.java b/android/app/src/main/java/com/mindful/android/utils/AppConstants.java index 0a75380..74899c6 100644 --- a/android/app/src/main/java/com/mindful/android/utils/AppConstants.java +++ b/android/app/src/main/java/com/mindful/android/utils/AppConstants.java @@ -16,8 +16,8 @@ public class AppConstants { public static final String FLUTTER_METHOD_CHANNEL = "com.mindful.android.methodchannel"; // Extra intent data - public static final String INTENT_EXTRA_IS_SELF_RESTART = "com.mindful.android.isSelfRestart"; public static final String INTENT_EXTRA_INITIAL_ROUTE = "com.mindful.android.initialRoute"; + public static final String INTENT_EXTRA_IS_SELF_RESTART = "com.mindful.android.isSelfRestart"; public static final String INTENT_EXTRA_PACKAGE_NAME = "com.mindful.android.launchedAppPackageName"; public static final String INTENT_EXTRA_DIALOG_INFO = "com.mindful.android.dialogInformation"; public static final String INTENT_EXTRA_MAX_PROGRESS = "com.mindful.android.maxProgress"; diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index c3a2663..29241f4 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -10,7 +10,6 @@ ~ */ --> - Mindful Mindful アプリは、ユーザー補助を使用して、ウェブサイトやアプリのショート動画をブロックします。ブロックリストに登録されたURLやコンテンツへのアクセスを制限することで、集中力を維持するのに役立ちます。 \n\n⚠️注意:Mindful はプライバシーを最優先に考えています。100%安全でオフラインで動作します。個人データの収集や保存は一切行いません。 Mindful は、ユーザーのデータを収集、保存、または送信することはありません。無料でオープンソースのソフトウェア(FOSS)なので、ソースコードを自由に確認・変更できます。管理者権限は、アプリの正常な動作に必要なシステム操作にのみ使用され、プライバシーは完全に保護されます。 通知を許可する diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 418abd4..d15a8c9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -12,7 +12,6 @@ - Mindful The Mindful app uses accessibility services to block websites and short-form content on apps. It checks URLs and content against your blocklist and prevents access to help you stay focused. \n\n⚠️Note: Your privacy is our priority. diff --git a/lib/core/services/routing_service.dart b/lib/core/services/routing_service.dart index c01c1f0..7608eea 100644 --- a/lib/core/services/routing_service.dart +++ b/lib/core/services/routing_service.dart @@ -23,17 +23,27 @@ class RoutingService { /// Initialize routing with the context Future init(BuildContext context) async { - final intentData = MethodChannelService.instance.intentData; + /// Validate the targeting route + final targetRoute = MethodChannelService.instance.intentData.route; + if (!AppRoutes.routes.containsKey(targetRoute)) return; - /// Check and push user to upcoming notifications screen - if (intentData.route == AppRoutes.upcomingNotificationsScreen) { + /// Push the user to targeted route + if (targetRoute == AppRoutes.upcomingNotificationsScreen) { _openDelayedRoute(context, AppRoutes.upcomingNotificationsScreen); } } - void _openDelayedRoute(BuildContext context, String validRouteName) async { - await Future.delayed(1500.ms); + void _openDelayedRoute( + BuildContext context, + String validRouteName, { + Object? arguments, + }) async { + await Future.delayed(500.ms); if (!context.mounted) return; - Navigator.of(context).pushNamed(validRouteName); + + Navigator.of(context).pushNamed( + validRouteName, + arguments: arguments, + ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 027e062..a31b343 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -570,6 +570,8 @@ "crash_logs_info": "If you encounter any issue, you can report it on GitHub along with the log file. The file will include details such as your device's manufacturer, model, Android version, SDK version, and crash logs. This information will help us identify and resolve the problem more effectively.", "crash_logs_export_tile_title": "Export crash logs", "crash_logs_export_tile_subtitle": "Export crash logs to a json file.", + "crash_logs_view_tile_title": "View logs", + "crash_logs_view_tile_subtitle": "Explore stored crash logs.", "crash_logs_clear_tile_title": "Clear logs", "crash_logs_clear_tile_subtitle": "Delete all crash logs from database.", "crash_logs_clear_dialog_info": "Are you sure you wish to clear all crash logs from the database?", diff --git a/lib/models/intent_data.dart b/lib/models/intent_data.dart index 407f916..6d26a80 100644 --- a/lib/models/intent_data.dart +++ b/lib/models/intent_data.dart @@ -12,27 +12,30 @@ import 'package:flutter/material.dart'; @immutable class IntentData { - /// Flag indicating if the app is restarted by itself (after importing database). - final bool isSelfRestart; - - /// Route name passed through Intent when starting the app + /// Route name passed through Intent when starting the app. + /// + /// User will be forwarded to this route's screen final String route; - /// Package name of the app whose Time Limit Exceeded dialog's emergency button is clicked. - /// This is forwarded by the overlay dialog service to show the emergency button on the dashboard. - final String targetedPackage; + /// Flag indicating if the app is restarted by itself (after importing database). + final bool extraIsSelfStart; + + /// Package name of the app whose overlay dialog's emergency button is clicked. + /// + /// This is forwarded by the overlay dialog service to open app dashboard. + final String extraPackageName; const IntentData({ - this.isSelfRestart = false, - this.route = '', - this.targetedPackage = '', + this.route = "", + this.extraIsSelfStart = false, + this.extraPackageName = "", }); factory IntentData.fromMap(Map map) { return IntentData( - isSelfRestart: map['isSelfRestart'] ?? false, route: map['route'] ?? '', - targetedPackage: map['targetedPackage'] ?? '', + extraIsSelfStart: map['extraIsSelfStart'] ?? false, + extraPackageName: map['extraPackageName'] ?? '', ); } } diff --git a/lib/providers/bedtime_provider.dart b/lib/providers/bedtime_provider.dart index 8954111..49c6986 100644 --- a/lib/providers/bedtime_provider.dart +++ b/lib/providers/bedtime_provider.dart @@ -31,7 +31,7 @@ class BedtimeScheduleNotifier extends StateNotifier { final dao = DriftDbService.instance.driftDb.uniqueRecordsDao; state = await dao.loadBedtimeSchedule(); - if (MethodChannelService.instance.intentData.isSelfRestart) { + if (MethodChannelService.instance.intentData.extraIsSelfStart) { if (state.isScheduleOn && state.distractingApps.isNotEmpty && state.scheduleDurationInMins >= 30) { diff --git a/lib/providers/mindful_settings_provider.dart b/lib/providers/mindful_settings_provider.dart index 639f187..b70da33 100644 --- a/lib/providers/mindful_settings_provider.dart +++ b/lib/providers/mindful_settings_provider.dart @@ -39,7 +39,7 @@ class MindfulSettingsNotifier extends StateNotifier { await MethodChannelService.instance .updateLocale(languageCode: state.localeCode); - if (MethodChannelService.instance.intentData.isSelfRestart) { + if (MethodChannelService.instance.intentData.extraIsSelfStart) { await MethodChannelService.instance .setDataResetTime(state.dataResetTime.toMinutes); } diff --git a/lib/providers/notification_schedules_provider.dart b/lib/providers/notification_schedules_provider.dart index af18cb7..87c8589 100644 --- a/lib/providers/notification_schedules_provider.dart +++ b/lib/providers/notification_schedules_provider.dart @@ -38,7 +38,7 @@ class NotificationSchedulesNotifier /// Check if no schedules then initialize with defaults if (schedules.isEmpty) await _createInitialSchedules(); - if (MethodChannelService.instance.intentData.isSelfRestart) { + if (MethodChannelService.instance.intentData.extraIsSelfStart) { await _updateNativeSchedules(); } diff --git a/lib/providers/shared_unique_data_provider.dart b/lib/providers/shared_unique_data_provider.dart index 73d996d..21eec63 100644 --- a/lib/providers/shared_unique_data_provider.dart +++ b/lib/providers/shared_unique_data_provider.dart @@ -30,7 +30,7 @@ class SharedDataNotifier extends StateNotifier { final dao = DriftDbService.instance.driftDb.uniqueRecordsDao; state = await dao.loadSharedData(); - if (MethodChannelService.instance.intentData.isSelfRestart) { + if (MethodChannelService.instance.intentData.extraIsSelfStart) { await MethodChannelService.instance .updateExcludedApps(state.excludedApps); } diff --git a/lib/providers/wellbeing_provider.dart b/lib/providers/wellbeing_provider.dart index cb1f946..b694858 100644 --- a/lib/providers/wellbeing_provider.dart +++ b/lib/providers/wellbeing_provider.dart @@ -33,7 +33,7 @@ class WellBeingNotifier extends StateNotifier { _dao = DriftDbService.instance.driftDb.uniqueRecordsDao; state = await _dao.loadWellBeingSettings(); - if (MethodChannelService.instance.intentData.isSelfRestart) { + if (MethodChannelService.instance.intentData.extraIsSelfStart) { await MethodChannelService.instance.updateWellBeingSettings(state); } diff --git a/lib/ui/common/application_icon.dart b/lib/ui/common/application_icon.dart index daa1391..26d709a 100644 --- a/lib/ui/common/application_icon.dart +++ b/lib/ui/common/application_icon.dart @@ -28,35 +28,32 @@ class ApplicationIcon extends StatelessWidget { @override Widget build(BuildContext context) { - if (app.packageName == AppConstants.removedAppPackage) { - return _createIcon(FluentIcons.delete_24_regular, context); - } else if (app.packageName == AppConstants.tetheringAppPackage) { - return _createIcon(FluentIcons.communication_24_regular, context); - } else { - return CircleAvatar( - backgroundColor: Colors.transparent, - radius: size, - child: ClipRRect( - borderRadius: BorderRadius.circular(size), - child: Image.memory( - app.icon, - color: isGrayedOut ? Colors.white : null, - colorBlendMode: isGrayedOut ? BlendMode.saturation : null, - ), - ), - ); - } - } + final useCustomIcon = app.packageName == AppConstants.removedAppPackage || + app.packageName == AppConstants.tetheringAppPackage; - Widget _createIcon(IconData iconData, BuildContext context) { return CircleAvatar( + backgroundColor: + useCustomIcon ? Theme.of(context).focusColor : Colors.transparent, radius: size, - backgroundColor: Theme.of(context).focusColor, - child: Icon( - iconData, - size: size, - color: Theme.of(context).iconTheme.color, + child: ClipRRect( + borderRadius: BorderRadius.circular(size), + child: useCustomIcon + ? _resolveIcon() + : Image.memory( + app.icon, + color: isGrayedOut ? Colors.white : null, + colorBlendMode: isGrayedOut ? BlendMode.saturation : null, + ), ), ); } + + Widget _resolveIcon() { + return Icon( + app.packageName == AppConstants.tetheringAppPackage + ? FluentIcons.communication_20_filled + : FluentIcons.delete_20_filled, + size: size, + ); + } } diff --git a/lib/ui/onboarding/onboarding_screen.dart b/lib/ui/onboarding/onboarding_screen.dart index 64a3191..0e1886f 100644 --- a/lib/ui/onboarding/onboarding_screen.dart +++ b/lib/ui/onboarding/onboarding_screen.dart @@ -113,7 +113,7 @@ class _OnboardingState extends ConsumerState { } void _skipToLastPage() { - if (mounted) { + if (mounted && widget.isOnboardingDone) { _controller.animateToPage( _pages.length - 1, duration: _animDuration, diff --git a/lib/ui/screens/home/home_screen.dart b/lib/ui/screens/home/home_screen.dart index 409fc8b..f43ec5a 100644 --- a/lib/ui/screens/home/home_screen.dart +++ b/lib/ui/screens/home/home_screen.dart @@ -51,7 +51,7 @@ class _HomeScreenState extends ConsumerState { _showDonationDialog(); final targetPackage = - MethodChannelService.instance.intentData.targetedPackage; + MethodChannelService.instance.intentData.extraPackageName; /// Return if no target package try routing instead if (targetPackage.isEmpty) { From 9c5cee974a8e2d5240bb53f6bfd8b278dfaf29ca Mon Sep 17 00:00:00 2001 From: Pawan Nagar Date: Fri, 3 Jan 2025 21:36:48 +0530 Subject: [PATCH 5/5] Added domain based nsfw blocking on Quetta Browser --- .../services/MindfulAccessibilityService.java | 7 ++++--- lib/ui/splash_screen.dart | 17 +++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/mindful/android/services/MindfulAccessibilityService.java b/android/app/src/main/java/com/mindful/android/services/MindfulAccessibilityService.java index cd6aa94..b7c0230 100644 --- a/android/app/src/main/java/com/mindful/android/services/MindfulAccessibilityService.java +++ b/android/app/src/main/java/com/mindful/android/services/MindfulAccessibilityService.java @@ -87,14 +87,15 @@ public class MindfulAccessibilityService extends AccessibilityService implements * These are used to retrieve/extract url from the browsers. */ private final HashSet mUrlBarNodeIds = new HashSet<>(Set.of( - ":id/url_bar", - ":id/mozac_browser_toolbar_url_view", + ":id/url_bar", // Chrome + ":id/mozac_browser_toolbar_url_view", // Firefox ":id/url", ":id/search", ":id/url_field", ":id/location_bar_edit_text", ":id/addressbarEdit", - ":id/bro_omnibar_address_title_text" + ":id/bro_omnibar_address_title_text", + ":id/cbn_tv_title" // Quetta Browser )); // Fixed thread pool for parallel event processing diff --git a/lib/ui/splash_screen.dart b/lib/ui/splash_screen.dart index 6c47fdb..d0d1625 100644 --- a/lib/ui/splash_screen.dart +++ b/lib/ui/splash_screen.dart @@ -63,15 +63,16 @@ class _SplashScreenState extends ConsumerState { initializeNecessaryProviders(ref); } - _isAccessProtected - ? _authenticate() - : Future.delayed( - _haveAllEssentialPermissions && _isOnboardingDone ? 750.ms : 0.ms, - _pushNextScreen, - ); + _isAccessProtected ? _authenticate() : _pushNextScreen(true); } - void _pushNextScreen() { + void _pushNextScreen(bool shouldDelay) async { + await Future.delayed( + shouldDelay && _haveAllEssentialPermissions && _isOnboardingDone + ? 1250.ms + : 0.ms, + ); + if (!mounted) return; if (_haveAllEssentialPermissions && _isOnboardingDone) { @@ -90,7 +91,7 @@ class _SplashScreenState extends ConsumerState { void _authenticate() async { final isSuccess = await AuthService.instance.authenticate(); - if (isSuccess) _pushNextScreen(); + if (isSuccess) _pushNextScreen(false); } @override