I am creating widget app in react native cli. It is a countdown widget that has 5 backgrounds which the user can pick. And the user can also upload custom image to the background of the widget. When i tried to use NativeModules.RNWidgetModule, it is being null. How can i solve this?
(I am not IOS developer. I wrote ios codes with AI.)
SkinPicker.ts
/* eslint-disable react-native/no-inline-styles */import React from 'react';import { View, TouchableOpacity, StyleSheet, Text, BackHandler, Platform,} from 'react-native';import LinearGradient from 'react-native-linear-gradient';import { NativeModules } from 'react-native';const { WidgetModule } = NativeModules;// Helper to apply skinconst setWidgetSkin = (skinName: string) => { if (Platform.OS === 'android'&& WidgetModule) { WidgetModule.setSkin(skinName); } if (Platform.OS === 'ios'&& NativeModules.RNWidgetModule) { NativeModules.RNWidgetModule.setSkin(skinName); NativeModules.RNWidgetModule.reloadWidgets(); } if (Platform.OS === 'android') { BackHandler.exitApp(); }};// Skin definitionsconst skins = [ { name: 'FIDESZ', type: 'solid', color: '#FF6A13', }, { name: 'TISZA', type: 'gradient', colors: ['#24B573', '#ED4551'], }, { name: 'KUTYAPART', type: 'dots', // two-color split + two red dots colors: ['#FFFFFF', '#000000'], dotColor: '#DA0000', }, { name: 'DK', type: 'gradient', colors: ['#0062A7', '#C50067', '#FFD500', '#2DAAE1'], }, { name: 'MI_HAZANK', type: 'solid', color: '#678B1D', },];export default function SkinPicker() { return (<View><Text style={styles.headerText}>Háttér kiválasztása</Text><View style={styles.row}> {skins.map(skin => { if (skin.type === 'solid') { return (<TouchableOpacity key={skin.name} style={[styles.box, { backgroundColor: skin.color }]} onPress={() => setWidgetSkin(skin.name)} /> ); } else if (skin.type === 'gradient') { return (<TouchableOpacity key={skin.name} onPress={() => setWidgetSkin(skin.name)}><LinearGradient colors={skin.colors!} style={styles.box} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} /></TouchableOpacity> ); } else if (skin.type === 'dots') { // Split box + 2 red dots return (<TouchableOpacity key={skin.name} onPress={() => setWidgetSkin(skin.name)}><View style={[styles.box, { flexDirection: 'row' }]}><View style={{ flex: 1, backgroundColor: skin.colors![0] }} /><View style={{ flex: 1, backgroundColor: skin.colors![1] }} /> {/* Red dots */}<View style={[ styles.dot, { top: 5, left: 5, backgroundColor: skin.dotColor }, ]} /><View style={[ styles.dot, { bottom: 5, right: 5, backgroundColor: skin.dotColor }, ]} /></View></TouchableOpacity> ); } })}</View></View> );}const styles = StyleSheet.create({ headerText: { textAlign: 'center', fontSize: 20, marginBottom: 10, }, row: { flexDirection: 'row', justifyContent: 'space-around', gap: 10, }, box: { width: 50, height: 50, borderRadius: 8, borderWidth: 1, borderColor: '#333', overflow: 'hidden', }, dot: { position: 'absolute', width: 8, height: 8, borderRadius: 4, },});
RNWidgetModule.m
#import <React/RCTBridgeModule.h>@interface RCT_EXTERN_MODULE(RNWidgetModule, NSObject)RCT_EXTERN_METHOD(setSkin:(NSString *)skinName)RCT_EXTERN_METHOD(setCustomBackground:(NSString *)imageUri resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)RCT_EXTERN_METHOD(removeCustomBackground)RCT_EXTERN_METHOD(reloadWidgets)@end
RNWidgetModule.swift
import Foundationimport Reactimport WidgetKitimport UIKit@objc(RNWidgetModule)class RNWidgetModule: NSObject { @objc static func requiresMainQueueSetup() -> Bool { return false } @objc func setSkin(_ skinName: String) { DispatchQueue.main.async { guard let userDefaults = UserDefaults(suiteName: "group.ittazido") else { print("Failed to get UserDefaults with suite name") return } userDefaults.set(skinName, forKey: "selectedSkin") userDefaults.set(false, forKey: "isCustomBackground") userDefaults.removeObject(forKey: "customBackgroundData") WidgetCenter.shared.reloadAllTimelines() userDefaults.synchronize() print("Skin set to: \(skinName)") self.reloadWidgets() } } @objc func setCustomBackground(_ imageUri: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { DispatchQueue.main.async { guard let url = URL(string: imageUri) else { rejecter("INVALID_URI", "Invalid image URI", nil) return } // Handle different URI schemes var imageData: Data? if imageUri.hasPrefix("ph://") { // Photo library asset self.loadPhotoLibraryAsset(url: url) { data in if let data = data { self.saveCustomBackground(data: data, resolver: resolver, rejecter: rejecter) } else { rejecter("LOAD_FAILED", "Failed to load image from photo library", nil) } } return } else if imageUri.hasPrefix("file://") { // File system let filePath = url.path imageData = NSData(contentsOfFile: filePath) as Data? } else if imageUri.hasPrefix("data:") { // Base64 data URI if let range = imageUri.range(of: ",") { let base64String = String(imageUri[range.upperBound...]) imageData = Data(base64Encoded: base64String) } } guard let data = imageData else { rejecter("LOAD_FAILED", "Failed to load image data", nil) return } self.saveCustomBackground(data: data, resolver: resolver, rejecter: rejecter) } } private func loadPhotoLibraryAsset(url: URL, completion: @escaping (Data?) -> Void) { import Photos let assetId = url.absoluteString.replacingOccurrences(of: "ph://", with: "") let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) guard let asset = fetchResult.firstObject else { completion(nil) return } let imageManager = PHImageManager.default() let options = PHImageRequestOptions() options.isSynchronous = false options.deliveryMode = .highQualityFormat imageManager.requestImage(for: asset, targetSize: CGSize(width: 800, height: 400), contentMode: .aspectFill, options: options) { image, _ in guard let image = image else { completion(nil) return } completion(image.jpegData(compressionQuality: 0.8)) } } private func saveCustomBackground(data: Data, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { guard let userDefaults = UserDefaults(suiteName: "group.ittazido") else { rejecter("USERDEFAULTS_ERROR", "Failed to get UserDefaults", nil) return } // Resize image if needed guard let image = UIImage(data: data) else { rejecter("INVALID_IMAGE", "Invalid image data", nil) return } let resizedImage = self.resizeImage(image: image, maxWidth: 800, maxHeight: 400) guard let resizedData = resizedImage.jpegData(compressionQuality: 0.8) else { rejecter("RESIZE_FAILED", "Failed to resize image", nil) return } userDefaults.set(true, forKey: "isCustomBackground") userDefaults.set(resizedData, forKey: "customBackgroundData") userDefaults.synchronize() self.reloadWidgets() resolver("Custom background set successfully") } @objc func removeCustomBackground() { DispatchQueue.main.async { guard let userDefaults = UserDefaults(suiteName: "group.ittazido") else { return } userDefaults.set(false, forKey: "isCustomBackground") userDefaults.removeObject(forKey: "customBackgroundData") userDefaults.synchronize() self.reloadWidgets() } } @objc func reloadWidgets() { DispatchQueue.main.async { if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() print("Widgets reloaded") } } } private func resizeImage(image: UIImage, maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage { let size = image.size if size.width <= maxWidth && size.height <= maxHeight { return image } let widthRatio = maxWidth / size.width let heightRatio = maxHeight / size.height let ratio = min(widthRatio, heightRatio) let newSize = CGSize(width: size.width * ratio, height: size.height * ratio) UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) image.draw(in: CGRect(origin: .zero, size: newSize)) let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return newImage ?? image }}// MARK: - React Native Bridgeextension RNWidgetModule: RCTBridgeModule { static func moduleName() -> String! { return "RNWidgetModule" }}
CountdownWidget.swift
import WidgetKitimport SwiftUIimport Foundationstruct CountdownData { let targetDate: Date let lastFetchTime: Date}enum WidgetSkin: String, CaseIterable { case fidesz = "FIDESZ" case tisza = "TISZA" case kutyapart = "KUTYAPART" case dk = "DK" case miHazank = "MI_HAZANK"}struct CountdownTimelineProvider: TimelineProvider { typealias Entry = CountdownEntry func placeholder(in context: Context) -> CountdownEntry { CountdownEntry(date: Date(), targetDate: getDefaultTargetDate()) } func getSnapshot(in context: Context, completion: @escaping (CountdownEntry) -> Void) { let entry = CountdownEntry(date: Date(), targetDate: getTargetDate()) completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline<CountdownEntry>) -> Void) { fetchTargetDateFromAPI { targetDate in var entries: [CountdownEntry] = [] let currentDate = Date() // Create entries for every second for the next minute for secondOffset in 0..<60 { let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset, to: currentDate)! let entry = CountdownEntry(date: entryDate, targetDate: targetDate) entries.append(entry) } // Update every minute let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!)) completion(timeline) } } private func fetchTargetDateFromAPI(completion: @escaping (Date) -> Void) { guard let url = URL(string: "https://api.lefiko.hu/time") else { completion(getDefaultTargetDate()) return } URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let timeString = json["time"] as? String else { completion(getDefaultTargetDate()) return } let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd H:mm:ss" formatter.timeZone = TimeZone(identifier: "Europe/Budapest") if let date = formatter.date(from: timeString) { // Cache the result UserDefaults(suiteName: "group.ittazido")?.set(date, forKey: "targetDate") UserDefaults(suiteName: "group.ittazido")?.set(Date(), forKey: "lastFetchTime") completion(date) } else { completion(getDefaultTargetDate()) } }.resume() } private func getTargetDate() -> Date { guard let userDefaults = UserDefaults(suiteName: "group.ittazido"), let cachedDate = userDefaults.object(forKey: "targetDate") as? Date, let lastFetch = userDefaults.object(forKey: "lastFetchTime") as? Date, Date().timeIntervalSince(lastFetch) < 600 else { return getDefaultTargetDate() } return cachedDate } private func getDefaultTargetDate() -> Date { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd H:mm:ss" formatter.timeZone = TimeZone(identifier: "Europe/Budapest") return formatter.date(from: "2026-04-12 06:00:00") ?? Date() }}struct CountdownEntry: TimelineEntry { let date: Date let targetDate: Date}struct CountdownWidgetEntryView: View { var entry: CountdownEntry @State private var now = Date() @AppStorage("selectedSkin", store: UserDefaults(suiteName: "group.ittazido")) private var selectedSkin: String = "DK" @AppStorage("isCustomBackground", store: UserDefaults(suiteName: "group.ittazido")) private var isCustomBackground: Bool = false private var currentSkin: WidgetSkin { WidgetSkin(rawValue: selectedSkin) ?? .dk } private var customBackgroundData: Data? { UserDefaults(suiteName: "group.ittazido")?.data(forKey: "customBackgroundData") } var body: some View { ZStack { // Background if isCustomBackground, let imageData = customBackgroundData, let uiImage = UIImage(data: imageData) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fill) } else { getSkinBackground(for: currentSkin) } // Overlay red dots for KUTYAPART if currentSkin == .kutyapart { VStack { HStack { Circle() .fill(Color.red) .frame(width: 8, height: 8) Spacer() } Spacer() HStack { Spacer() Circle() .fill(Color.red) .frame(width: 8, height: 8) } } .padding(5) } // Countdown text Text(getCountdownText()) .font(.system(size: 24, weight: .bold)) .foregroundColor(getTextColor(for: currentSkin)) .shadow(color: .black.opacity(0.5), radius: 2, x: 1, y: 1) .multilineTextAlignment(.center) } .containerBackground(for: .widget) { Color.clear } } private func getCountdownText() -> String { let now = entry.date let target = entry.targetDate let diff = target.timeIntervalSince(now) if diff > 0 { let days = Int(diff) / (24 * 60 * 60) let hours = (Int(diff) % (24 * 60 * 60)) / (60 * 60) let minutes = (Int(diff) % (60 * 60)) / 60 let seconds = Int(diff) % 60 return String(format: "%d nap %02d:%02d:%02d", days, hours, minutes, seconds) } else if diff >= -24 * 60 * 60 { return "ITT AZ IDŐ!" } else { return "4 év múlva újra találkozunk!" } } private func getSkinBackground(for skin: WidgetSkin) -> some View { Group { switch skin { case .fidesz: RoundedRectangle(cornerRadius: 8) .fill(Color(red: 1.0, green: 0.416, blue: 0.075)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black.opacity(0.2), lineWidth: 1) ) case .tisza: RoundedRectangle(cornerRadius: 8) .fill( LinearGradient( colors: [ Color(red: 0.141, green: 0.710, blue: 0.451), Color(red: 0.929, green: 0.271, blue: 0.318) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black.opacity(0.2), lineWidth: 1) ) case .kutyapart: RoundedRectangle(cornerRadius: 8) .fill( LinearGradient( colors: [Color.white, Color.black], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black.opacity(0.2), lineWidth: 1) ) case .dk: RoundedRectangle(cornerRadius: 8) .fill( LinearGradient( colors: [ Color(red: 0.004, green: 0.384, blue: 0.655), Color(red: 0.773, green: 0.000, blue: 0.404), Color(red: 1.0, green: 0.835, blue: 0.0) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black.opacity(0.2), lineWidth: 1) ) case .miHazank: RoundedRectangle(cornerRadius: 8) .fill(Color(red: 0.404, green: 0.545, blue: 0.114)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black.opacity(0.2), lineWidth: 1) ) } } } private func getTextColor(for skin: WidgetSkin) -> Color { switch skin { case .kutyapart: return .black default: return .white } }}struct CountdownWidget: Widget { let kind: String = "CountdownWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CountdownTimelineProvider()) { entry in CountdownWidgetEntryView(entry: entry) } .configurationDisplayName("Countdown Widget") .description("Displays a countdown to the target date.") .supportedFamilies([.systemSmall, .systemMedium]) }}#Preview(as: .systemSmall) { CountdownWidget()} timeline: { let targetDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date() CountdownEntry(date: .now, targetDate: targetDate)}