rn-persistent-timer 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +607 -0
  2. package/android/.gradle/7.4.2/checksums/checksums.lock +0 -0
  3. package/android/.gradle/7.4.2/fileChanges/last-build.bin +0 -0
  4. package/android/.gradle/7.4.2/fileHashes/fileHashes.lock +0 -0
  5. package/android/.gradle/7.4.2/gc.properties +0 -0
  6. package/android/.gradle/vcs-1/gc.properties +0 -0
  7. package/android/build.gradle +15 -0
  8. package/android/src/main/java/com/rnpersistenttimer/RNPersistentTimerModule.java +164 -0
  9. package/android/src/main/java/com/rnpersistenttimer/RNPersistentTimerPackage.java +27 -0
  10. package/android/src/main/java/com/rnpersistenttimer/TimerForegroundService.java +280 -0
  11. package/ios/RNPersistentTimer.h +10 -0
  12. package/ios/RNPersistentTimer.m +221 -0
  13. package/lib/commonjs/NativeTimerModule.js +46 -0
  14. package/lib/commonjs/NativeTimerModule.js.map +1 -0
  15. package/lib/commonjs/PersistentTimerManager.js +337 -0
  16. package/lib/commonjs/PersistentTimerManager.js.map +1 -0
  17. package/lib/commonjs/index.js +76 -0
  18. package/lib/commonjs/index.js.map +1 -0
  19. package/lib/commonjs/types.js +2 -0
  20. package/lib/commonjs/types.js.map +1 -0
  21. package/lib/commonjs/usePersistentTimer.js +159 -0
  22. package/lib/commonjs/usePersistentTimer.js.map +1 -0
  23. package/lib/commonjs/utils.js +112 -0
  24. package/lib/commonjs/utils.js.map +1 -0
  25. package/lib/module/NativeTimerModule.js +40 -0
  26. package/lib/module/NativeTimerModule.js.map +1 -0
  27. package/lib/module/PersistentTimerManager.js +329 -0
  28. package/lib/module/PersistentTimerManager.js.map +1 -0
  29. package/lib/module/index.js +17 -0
  30. package/lib/module/index.js.map +1 -0
  31. package/lib/module/types.js +2 -0
  32. package/lib/module/types.js.map +1 -0
  33. package/lib/module/usePersistentTimer.js +153 -0
  34. package/lib/module/usePersistentTimer.js.map +1 -0
  35. package/lib/module/utils.js +100 -0
  36. package/lib/module/utils.js.map +1 -0
  37. package/lib/typescript/NativeTimerModule.d.ts +31 -0
  38. package/lib/typescript/NativeTimerModule.d.ts.map +1 -0
  39. package/lib/typescript/PersistentTimerManager.d.ts +37 -0
  40. package/lib/typescript/PersistentTimerManager.d.ts.map +1 -0
  41. package/lib/typescript/index.d.ts +7 -0
  42. package/lib/typescript/index.d.ts.map +1 -0
  43. package/lib/typescript/types.d.ts +167 -0
  44. package/lib/typescript/types.d.ts.map +1 -0
  45. package/lib/typescript/usePersistentTimer.d.ts +16 -0
  46. package/lib/typescript/usePersistentTimer.d.ts.map +1 -0
  47. package/lib/typescript/utils.d.ts +36 -0
  48. package/lib/typescript/utils.d.ts.map +1 -0
  49. package/package.json +98 -0
  50. package/src/NativeTimerModule.ts +73 -0
  51. package/src/PersistentTimerManager.ts +410 -0
  52. package/src/index.ts +41 -0
  53. package/src/types.ts +198 -0
  54. package/src/usePersistentTimer.tsx +173 -0
  55. package/src/utils.ts +91 -0
@@ -0,0 +1,164 @@
1
+ // Android: RNPersistentTimerModule.java
2
+ // Exposes native methods to JS and manages the ForegroundService
3
+
4
+ package com.rnpersistenttimer;
5
+
6
+ import android.content.Intent;
7
+ import android.os.Build;
8
+
9
+ import androidx.annotation.NonNull;
10
+
11
+ import com.facebook.react.bridge.Arguments;
12
+ import com.facebook.react.bridge.Promise;
13
+ import com.facebook.react.bridge.ReactApplicationContext;
14
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
15
+ import com.facebook.react.bridge.ReactMethod;
16
+ import com.facebook.react.bridge.ReadableMap;
17
+ import com.facebook.react.bridge.WritableArray;
18
+ import com.facebook.react.bridge.WritableMap;
19
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
20
+
21
+ import java.util.ArrayList;
22
+ import java.util.HashMap;
23
+ import java.util.List;
24
+ import java.util.Map;
25
+
26
+ public class RNPersistentTimerModule extends ReactContextBaseJavaModule {
27
+
28
+ public static final String MODULE_NAME = "RNPersistentTimer";
29
+ private static ReactApplicationContext reactContext;
30
+
31
+ // Active timers map: timerId -> start timestamp (ms)
32
+ private static final Map<String, Long> activeTimers = new HashMap<>();
33
+ private static final Map<String, Long> elapsedAtPause = new HashMap<>();
34
+
35
+ public RNPersistentTimerModule(ReactApplicationContext context) {
36
+ super(context);
37
+ reactContext = context;
38
+ }
39
+
40
+ @NonNull
41
+ @Override
42
+ public String getName() {
43
+ return MODULE_NAME;
44
+ }
45
+
46
+ // ─── JS-Exposed Methods ────────────────────────────────────────────────────
47
+
48
+ @ReactMethod
49
+ public void startTimer(ReadableMap options, Promise promise) {
50
+ try {
51
+ String timerId = options.getString("timerId");
52
+ String mode = options.hasKey("mode") ? options.getString("mode") : "stopwatch";
53
+ int duration = options.hasKey("duration") ? options.getInt("duration") : 0;
54
+ int elapsed = options.hasKey("elapsed") ? options.getInt("elapsed") : 0;
55
+ long startedAt = options.hasKey("startedAt") ? (long) options.getDouble("startedAt") : System.currentTimeMillis();
56
+ boolean showNotif = !options.hasKey("showNotification") || options.getBoolean("showNotification");
57
+
58
+ // Record start
59
+ activeTimers.put(timerId, startedAt - (elapsed * 1000L));
60
+
61
+ // Start foreground service
62
+ Intent serviceIntent = new Intent(reactContext, TimerForegroundService.class);
63
+ serviceIntent.putExtra("timerId", timerId);
64
+ serviceIntent.putExtra("mode", mode);
65
+ serviceIntent.putExtra("duration", duration);
66
+ serviceIntent.putExtra("elapsed", elapsed);
67
+ serviceIntent.putExtra("startedAt", startedAt);
68
+ serviceIntent.putExtra("showNotification", showNotif);
69
+ serviceIntent.setAction(TimerForegroundService.ACTION_START);
70
+
71
+ // Notification extras
72
+ if (options.hasKey("notification")) {
73
+ ReadableMap notif = options.getMap("notification");
74
+ if (notif != null) {
75
+ serviceIntent.putExtra("notif_title", notif.hasKey("title") ? notif.getString("title") : "Timer Running");
76
+ serviceIntent.putExtra("notif_body", notif.hasKey("body") ? notif.getString("body") : "{time}");
77
+ serviceIntent.putExtra("notif_color", notif.hasKey("color") ? notif.getString("color") : "#2196F3");
78
+ serviceIntent.putExtra("notif_icon", notif.hasKey("icon") ? notif.getString("icon") : "ic_notification");
79
+ serviceIntent.putExtra("notif_actions", notif.hasKey("showActions") && notif.getBoolean("showActions"));
80
+ }
81
+ }
82
+
83
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
84
+ reactContext.startForegroundService(serviceIntent);
85
+ } else {
86
+ reactContext.startService(serviceIntent);
87
+ }
88
+
89
+ promise.resolve(null);
90
+ } catch (Exception e) {
91
+ promise.reject("TIMER_START_ERROR", e.getMessage(), e);
92
+ }
93
+ }
94
+
95
+ @ReactMethod
96
+ public void stopTimer(String timerId, Promise promise) {
97
+ try {
98
+ activeTimers.remove(timerId);
99
+ elapsedAtPause.remove(timerId);
100
+
101
+ Intent serviceIntent = new Intent(reactContext, TimerForegroundService.class);
102
+ serviceIntent.setAction(TimerForegroundService.ACTION_STOP);
103
+ serviceIntent.putExtra("timerId", timerId);
104
+ reactContext.startService(serviceIntent);
105
+
106
+ promise.resolve(null);
107
+ } catch (Exception e) {
108
+ promise.reject("TIMER_STOP_ERROR", e.getMessage(), e);
109
+ }
110
+ }
111
+
112
+ @ReactMethod
113
+ public void getElapsed(String timerId, Promise promise) {
114
+ Long startedAt = activeTimers.get(timerId);
115
+ if (startedAt == null) {
116
+ Long paused = elapsedAtPause.get(timerId);
117
+ promise.resolve(paused != null ? paused.intValue() : 0);
118
+ } else {
119
+ long elapsed = (System.currentTimeMillis() - startedAt) / 1000;
120
+ promise.resolve((int) elapsed);
121
+ }
122
+ }
123
+
124
+ @ReactMethod
125
+ public void getActiveTimers(Promise promise) {
126
+ WritableArray arr = Arguments.createArray();
127
+ for (String id : activeTimers.keySet()) {
128
+ arr.pushString(id);
129
+ }
130
+ promise.resolve(arr);
131
+ }
132
+
133
+ @ReactMethod
134
+ public void cancelAll(Promise promise) {
135
+ activeTimers.clear();
136
+ elapsedAtPause.clear();
137
+ Intent serviceIntent = new Intent(reactContext, TimerForegroundService.class);
138
+ serviceIntent.setAction(TimerForegroundService.ACTION_CANCEL_ALL);
139
+ reactContext.startService(serviceIntent);
140
+ promise.resolve(null);
141
+ }
142
+
143
+ @ReactMethod
144
+ public void isKilledStateSupported(Promise promise) {
145
+ // Android supports killed-state via WorkManager + AsyncStorage restore
146
+ promise.resolve(true);
147
+ }
148
+
149
+ // ─── Event Emitter to JS ─────────────────────────────────────────────────
150
+
151
+ public static void sendEvent(String eventName, WritableMap params) {
152
+ if (reactContext != null && reactContext.hasActiveReactInstance()) {
153
+ reactContext
154
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
155
+ .emit(eventName, params);
156
+ }
157
+ }
158
+
159
+ // Required for NativeEventEmitter
160
+ @ReactMethod
161
+ public void addListener(String eventName) {}
162
+ @ReactMethod
163
+ public void removeListeners(Integer count) {}
164
+ }
@@ -0,0 +1,27 @@
1
+ // RNPersistentTimerPackage.java — React Native Package Registration
2
+
3
+ package com.rnpersistenttimer;
4
+
5
+ import com.facebook.react.ReactPackage;
6
+ import com.facebook.react.bridge.NativeModule;
7
+ import com.facebook.react.bridge.ReactApplicationContext;
8
+ import com.facebook.react.uimanager.ViewManager;
9
+
10
+ import java.util.ArrayList;
11
+ import java.util.Collections;
12
+ import java.util.List;
13
+
14
+ public class RNPersistentTimerPackage implements ReactPackage {
15
+
16
+ @Override
17
+ public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
18
+ List<NativeModule> modules = new ArrayList<>();
19
+ modules.add(new RNPersistentTimerModule(reactContext));
20
+ return modules;
21
+ }
22
+
23
+ @Override
24
+ public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
25
+ return Collections.emptyList();
26
+ }
27
+ }
@@ -0,0 +1,280 @@
1
+ // Android: TimerForegroundService.java
2
+ // Keeps the timer alive in background with a persistent notification
3
+
4
+ package com.rnpersistenttimer;
5
+
6
+ import android.app.Notification;
7
+ import android.app.NotificationChannel;
8
+ import android.app.NotificationManager;
9
+ import android.app.PendingIntent;
10
+ import android.app.Service;
11
+ import android.content.Intent;
12
+ import android.graphics.Color;
13
+ import android.os.Build;
14
+ import android.os.Handler;
15
+ import android.os.IBinder;
16
+ import android.os.Looper;
17
+
18
+ import androidx.annotation.Nullable;
19
+ import androidx.core.app.NotificationCompat;
20
+
21
+ import com.facebook.react.bridge.Arguments;
22
+ import com.facebook.react.bridge.WritableMap;
23
+
24
+ import java.util.HashMap;
25
+ import java.util.Map;
26
+ import java.util.Locale;
27
+
28
+ public class TimerForegroundService extends Service {
29
+
30
+ public static final String ACTION_START = "ACTION_START";
31
+ public static final String ACTION_STOP = "ACTION_STOP";
32
+ public static final String ACTION_CANCEL_ALL = "ACTION_CANCEL_ALL";
33
+ public static final String ACTION_PAUSE = "ACTION_PAUSE";
34
+ public static final String ACTION_RESUME = "ACTION_RESUME";
35
+
36
+ private static final String CHANNEL_ID = "rn_persistent_timer_channel";
37
+ private static final String CHANNEL_NAME = "Timer Notifications";
38
+ private static final int NOTIF_ID = 7771;
39
+
40
+ // Active timers: timerId -> startedAtMs (adjusted for elapsed)
41
+ private final Map<String, Long> timerStartTimes = new HashMap<>();
42
+ private final Map<String, Integer> timerDurations = new HashMap<>();
43
+ private final Map<String, String> timerModes = new HashMap<>();
44
+
45
+ private Handler handler;
46
+ private Runnable tickRunnable;
47
+ private boolean running = false;
48
+
49
+ // Notification config (last started timer drives the notification)
50
+ private String notifTitle = "Timer Running";
51
+ private String notifBody = "{time}";
52
+ private String notifColor = "#2196F3";
53
+ private boolean showActions = true;
54
+
55
+ @Override
56
+ public void onCreate() {
57
+ super.onCreate();
58
+ handler = new Handler(Looper.getMainLooper());
59
+ createNotificationChannel();
60
+ }
61
+
62
+ @Override
63
+ public int onStartCommand(Intent intent, int flags, int startId) {
64
+ if (intent == null) return START_STICKY;
65
+
66
+ String action = intent.getAction();
67
+ if (action == null) return START_STICKY;
68
+
69
+ switch (action) {
70
+ case ACTION_START:
71
+ handleStart(intent);
72
+ break;
73
+ case ACTION_STOP:
74
+ handleStop(intent.getStringExtra("timerId"));
75
+ break;
76
+ case ACTION_CANCEL_ALL:
77
+ handleCancelAll();
78
+ break;
79
+ case ACTION_PAUSE:
80
+ // handled from notification button
81
+ break;
82
+ }
83
+ return START_STICKY; // Restart if system kills service
84
+ }
85
+
86
+ private void handleStart(Intent intent) {
87
+ String timerId = intent.getStringExtra("timerId");
88
+ String mode = intent.getStringExtra("mode");
89
+ int elapsed = intent.getIntExtra("elapsed", 0);
90
+ long startedAt = intent.getLongExtra("startedAt", System.currentTimeMillis());
91
+ int duration = intent.getIntExtra("duration", 0);
92
+ boolean showNotif = intent.getBooleanExtra("showNotification", true);
93
+
94
+ notifTitle = intent.getStringExtra("notif_title") != null ? intent.getStringExtra("notif_title") : notifTitle;
95
+ notifBody = intent.getStringExtra("notif_body") != null ? intent.getStringExtra("notif_body") : notifBody;
96
+ notifColor = intent.getStringExtra("notif_color") != null ? intent.getStringExtra("notif_color") : notifColor;
97
+ showActions = intent.getBooleanExtra("notif_actions", true);
98
+
99
+ // Adjust startedAt for already-elapsed seconds
100
+ long adjustedStart = startedAt - ((long) elapsed * 1000);
101
+ timerStartTimes.put(timerId, adjustedStart);
102
+ timerModes.put(timerId, mode != null ? mode : "stopwatch");
103
+ timerDurations.put(timerId, duration);
104
+
105
+ if (!running) {
106
+ running = true;
107
+ Notification notification = showNotif ? buildNotification("00:00:00") : buildSilentNotification();
108
+ startForeground(NOTIF_ID, notification);
109
+ startTicking();
110
+ }
111
+ }
112
+
113
+ private void handleStop(String timerId) {
114
+ if (timerId == null) return;
115
+ timerStartTimes.remove(timerId);
116
+ timerModes.remove(timerId);
117
+ timerDurations.remove(timerId);
118
+
119
+ if (timerStartTimes.isEmpty()) {
120
+ stopTicking();
121
+ stopForeground(true);
122
+ stopSelf();
123
+ running = false;
124
+ }
125
+ }
126
+
127
+ private void handleCancelAll() {
128
+ timerStartTimes.clear();
129
+ timerModes.clear();
130
+ timerDurations.clear();
131
+ stopTicking();
132
+ stopForeground(true);
133
+ stopSelf();
134
+ running = false;
135
+ }
136
+
137
+ private void startTicking() {
138
+ tickRunnable = new Runnable() {
139
+ @Override
140
+ public void run() {
141
+ if (!running) return;
142
+
143
+ long now = System.currentTimeMillis();
144
+ String displayTime = "00:00:00";
145
+
146
+ for (Map.Entry<String, Long> entry : timerStartTimes.entrySet()) {
147
+ String timerId = entry.getKey();
148
+ long startedAt = entry.getValue();
149
+ String mode = timerModes.getOrDefault(timerId, "stopwatch");
150
+ int duration = timerDurations.getOrDefault(timerId, 0);
151
+
152
+ long elapsedSeconds = (now - startedAt) / 1000;
153
+
154
+ int displaySeconds;
155
+ if ("countdown".equals(mode)) {
156
+ displaySeconds = (int) Math.max(0, duration - elapsedSeconds);
157
+ if (displaySeconds == 0) {
158
+ // Timer completed
159
+ WritableMap params = Arguments.createMap();
160
+ params.putString("timerId", timerId);
161
+ RNPersistentTimerModule.sendEvent("RNPersistentTimer_complete", params);
162
+ }
163
+ } else {
164
+ displaySeconds = (int) elapsedSeconds;
165
+ }
166
+
167
+ displayTime = formatSeconds(displaySeconds);
168
+
169
+ // Emit tick to JS
170
+ WritableMap params = Arguments.createMap();
171
+ params.putString("timerId", timerId);
172
+ params.putInt("elapsed", (int) elapsedSeconds);
173
+ params.putInt("remaining", "countdown".equals(mode) ? (int) Math.max(0, duration - elapsedSeconds) : -1);
174
+ RNPersistentTimerModule.sendEvent("RNPersistentTimer_tick", params);
175
+ }
176
+
177
+ // Update notification with last timer's display time
178
+ updateNotification(displayTime);
179
+
180
+ handler.postDelayed(this, 1000);
181
+ }
182
+ };
183
+ handler.post(tickRunnable);
184
+ }
185
+
186
+ private void stopTicking() {
187
+ if (tickRunnable != null) {
188
+ handler.removeCallbacks(tickRunnable);
189
+ tickRunnable = null;
190
+ }
191
+ }
192
+
193
+ // ─── Notification ─────────────────────────────────────────────────────────
194
+
195
+ private void createNotificationChannel() {
196
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
197
+ NotificationChannel channel = new NotificationChannel(
198
+ CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW
199
+ );
200
+ channel.setDescription("Persistent timer notifications");
201
+ channel.setSound(null, null);
202
+ NotificationManager nm = getSystemService(NotificationManager.class);
203
+ if (nm != null) nm.createNotificationChannel(channel);
204
+ }
205
+ }
206
+
207
+ private Notification buildNotification(String timeStr) {
208
+ Intent openApp = getPackageManager().getLaunchIntentForPackage(getPackageName());
209
+ PendingIntent pendingIntent = PendingIntent.getActivity(
210
+ this, 0, openApp,
211
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
212
+ ? PendingIntent.FLAG_IMMUTABLE : 0
213
+ );
214
+
215
+ int colorInt;
216
+ try { colorInt = Color.parseColor(notifColor); }
217
+ catch (Exception e) { colorInt = Color.parseColor("#2196F3"); }
218
+
219
+ String body = notifBody.replace("{time}", timeStr);
220
+
221
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
222
+ .setContentTitle(notifTitle)
223
+ .setContentText(body)
224
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
225
+ .setColor(colorInt)
226
+ .setOngoing(true)
227
+ .setOnlyAlertOnce(true)
228
+ .setSilent(true)
229
+ .setContentIntent(pendingIntent);
230
+
231
+ if (showActions) {
232
+ Intent pauseIntent = new Intent(this, TimerForegroundService.class);
233
+ pauseIntent.setAction(ACTION_PAUSE);
234
+ PendingIntent pausePending = PendingIntent.getService(
235
+ this, 1, pauseIntent,
236
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
237
+ ? PendingIntent.FLAG_IMMUTABLE : 0
238
+ );
239
+ builder.addAction(android.R.drawable.ic_media_pause, "Pause", pausePending);
240
+ }
241
+
242
+ return builder.build();
243
+ }
244
+
245
+ private Notification buildSilentNotification() {
246
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
247
+ .setContentTitle("Timer")
248
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
249
+ .setOngoing(true)
250
+ .setSilent(true)
251
+ .setPriority(NotificationCompat.PRIORITY_MIN)
252
+ .build();
253
+ }
254
+
255
+ private void updateNotification(String timeStr) {
256
+ Notification notification = buildNotification(timeStr);
257
+ NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
258
+ if (nm != null) nm.notify(NOTIF_ID, notification);
259
+ }
260
+
261
+ private static String formatSeconds(int totalSeconds) {
262
+ int h = totalSeconds / 3600;
263
+ int m = (totalSeconds % 3600) / 60;
264
+ int s = totalSeconds % 60;
265
+ return String.format(Locale.US, "%02d:%02d:%02d", h, m, s);
266
+ }
267
+
268
+ @Nullable
269
+ @Override
270
+ public IBinder onBind(Intent intent) {
271
+ return null;
272
+ }
273
+
274
+ @Override
275
+ public void onDestroy() {
276
+ stopTicking();
277
+ running = false;
278
+ super.onDestroy();
279
+ }
280
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * RNPersistentTimer.h
3
+ * Public header for the iOS native module.
4
+ */
5
+
6
+ #import <React/RCTEventEmitter.h>
7
+ #import <React/RCTBridgeModule.h>
8
+
9
+ @interface RNPersistentTimer : RCTEventEmitter <RCTBridgeModule>
10
+ @end