Compare commits
2 Commits
c4696f2d9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d09b5b356 | ||
|
|
a049129a35 |
@@ -18,6 +18,12 @@ migration:
|
||||
- platform: android
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: ios
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: macos
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
val hasReleaseKeystore = listOf(
|
||||
"keyAlias",
|
||||
"keyPassword",
|
||||
"storeFile",
|
||||
"storePassword",
|
||||
).all { key ->
|
||||
val value = keystoreProperties[key] as String?
|
||||
!value.isNullOrBlank()
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jamshalat.diary"
|
||||
@@ -30,12 +39,14 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
storeType = "PKCS12"
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
storeType = "PKCS12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +63,13 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// Use release keystore if configured, otherwise fallback to debug
|
||||
// signing so local release APK builds remain possible.
|
||||
signingConfig = if (hasReleaseKeystore) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<application
|
||||
android:label="Jamshalat Diary"
|
||||
android:name="${applicationName}"
|
||||
@@ -33,6 +38,21 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -65,6 +65,11 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
package com.jamshalat.diary
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import android.hardware.GeomagneticField
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
companion object {
|
||||
private const val GEOMAGNETIC_CHANNEL = "com.jamshalat.diary/geomagnetic"
|
||||
private const val DECLINATION_METHOD = "getDeclination"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, GEOMAGNETIC_CHANNEL)
|
||||
.setMethodCallHandler(::handleGeomagneticMethodCall)
|
||||
}
|
||||
|
||||
private fun handleGeomagneticMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.method != DECLINATION_METHOD) {
|
||||
result.notImplemented()
|
||||
return
|
||||
}
|
||||
|
||||
val latitude = call.argument<Double>("latitude")
|
||||
val longitude = call.argument<Double>("longitude")
|
||||
if (latitude == null || longitude == null) {
|
||||
result.error("INVALID_ARGS", "Latitude and longitude are required.", null)
|
||||
return
|
||||
}
|
||||
|
||||
val altitude = call.argument<Double>("altitude") ?: 0.0
|
||||
val timestamp = call.argument<Long>("timestamp") ?: System.currentTimeMillis()
|
||||
val field = GeomagneticField(
|
||||
latitude.toFloat(),
|
||||
longitude.toFloat(),
|
||||
altitude.toFloat(),
|
||||
timestamp,
|
||||
)
|
||||
result.success(field.declination.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.jamshalat.jamshalat_diary
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -1,5 +1,5 @@
|
||||
sdk.dir=/Users/dwindown/Library/Android/sdk
|
||||
flutter.sdk=/Users/dwindown/FlutterDev/flutter
|
||||
flutter.sdk=/opt/homebrew/share/flutter
|
||||
flutter.buildMode=release
|
||||
flutter.versionName=1.0.0
|
||||
flutter.versionCode=1
|
||||
BIN
assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
Normal file
BIN
assets/fonts/ScheherazadeNew-Bold.ttf
Normal file
BIN
assets/fonts/ScheherazadeNew-Regular.ttf
Normal file
BIN
assets/fonts/UthmanTN1-Ver10.otf
Normal file
BIN
assets/images/blob.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/images/logo_normal.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/images/logo_white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
248
docs/notification-plan.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Notification Plan (Alerts + Inbox)
|
||||
|
||||
Last updated: 2026-03-16
|
||||
Owner: Product + Mobile
|
||||
Scope: `jamshalat_diary` (Flutter)
|
||||
|
||||
## Implementation Status (2026-03-17)
|
||||
- Phase 1: Implemented.
|
||||
- Phase 2: Implemented (Notification Center + unread badge + inbox persistence).
|
||||
- Phase 3: Implemented (daily checklist reminder scheduling, streak-risk messages, weekly summary, quiet-hours and daily cap preferences).
|
||||
- Phase 4: Partially implemented via remote-content sync and remote-push ingestion bridge.
|
||||
- External pending for full Phase 4: SDK/provider wiring (FCM/APNs credentials, token registration, backend push dispatch).
|
||||
|
||||
## 1. Problem Statement
|
||||
Current app has mixed meaning for "notification":
|
||||
- Device push alert (time-sensitive, appears in system drawer)
|
||||
- In-app message feed (read-later information)
|
||||
|
||||
This causes unclear UX around the bell icon and settings.
|
||||
|
||||
## 2. Product Model
|
||||
Split into two clear products:
|
||||
|
||||
1. **Alerts** (`Pemberitahuan`)
|
||||
- Time-sensitive pushes to OS notification drawer.
|
||||
- Can ring/vibrate.
|
||||
- Expire quickly.
|
||||
|
||||
2. **Inbox** (`Pesan`)
|
||||
- In-app list of messages user can read later.
|
||||
- Has read/unread state.
|
||||
- No mandatory sound.
|
||||
|
||||
Default rule:
|
||||
- **Push only**: urgent + expiring event.
|
||||
- **Inbox only**: informational/non-urgent event.
|
||||
- **Push + Inbox**: important event that also needs follow-up context.
|
||||
|
||||
## 3. Terminology (User-Facing)
|
||||
- Bell icon label/context: `Pemberitahuan`
|
||||
- Notification center tabs:
|
||||
- `Alarm` (alert history + system-triggered items)
|
||||
- `Pesan` (inbox/read-later)
|
||||
|
||||
## 4. Event Policy Matrix
|
||||
| Event | Push Drawer | Inbox | Sound | Expiry |
|
||||
|---|---|---|---|---|
|
||||
| Adzan time entered | Yes | Optional (off by default) | Yes | At end of prayer window |
|
||||
| Iqamah reminder | Yes | No | Yes | At iqamah time |
|
||||
| Next prayer in 10 minutes | Optional | No | Soft | At prayer start |
|
||||
| Daily checklist reminder | Yes (if enabled) | No | Optional | End of day |
|
||||
| Streak at risk (Tilawah/Dzikir) | Optional | Yes | No | End of day |
|
||||
| Weekly worship summary | No | Yes | No | 7 days |
|
||||
| Permission blocked (notif/exact alarm/location) | Yes | Yes | No | When resolved |
|
||||
| Schedule/location stale | Yes | Yes | No | When resolved |
|
||||
| New app content (Doa/Hadits) | No | Yes | No | 30 days |
|
||||
|
||||
## 5. Architecture
|
||||
## 5.1 Channels
|
||||
- **Local scheduled alerts** (already started in app): Adzan + Iqamah.
|
||||
- **In-app inbox storage** (new): persisted read/unread messages.
|
||||
- **Remote push** (future): FCM/APNs for server-driven campaigns/events.
|
||||
|
||||
## 5.2 Data Sources
|
||||
- Prayer schedules: `MyQuranSholatService`.
|
||||
- User prefs: `AppSettings` + new notification preference model.
|
||||
- Device status: notification permission, exact alarm permission, location service.
|
||||
|
||||
## 5.3 Decision Engine
|
||||
Input:
|
||||
- event type
|
||||
- urgency
|
||||
- TTL
|
||||
- user preferences
|
||||
- cooldown/frequency cap
|
||||
|
||||
Output:
|
||||
- deliver to `push`, `inbox`, or both.
|
||||
|
||||
Pseudo rule:
|
||||
```text
|
||||
if event.isTimeCritical && event.ttlShort:
|
||||
push
|
||||
if event.needsFollowUp || event.referenceContent:
|
||||
inbox
|
||||
if event.push && event.inboxPolicy == mirror:
|
||||
push + inbox
|
||||
```
|
||||
|
||||
## 6. Data Model
|
||||
## 6.1 Inbox Item (Hive)
|
||||
Suggested new box: `notification_inbox`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "streak_risk | summary | system | content | prayer",
|
||||
"title": "string",
|
||||
"body": "string",
|
||||
"createdAt": "iso8601",
|
||||
"expiresAt": "iso8601|null",
|
||||
"readAt": "iso8601|null",
|
||||
"isPinned": false,
|
||||
"deeplink": "/route/path",
|
||||
"meta": {
|
||||
"cityId": "string|null",
|
||||
"prayerKey": "fajr|dhuhr|asr|maghrib|isha|null",
|
||||
"date": "yyyy-MM-dd|null"
|
||||
},
|
||||
"source": "local|remote",
|
||||
"dedupeKey": "string"
|
||||
}
|
||||
```
|
||||
|
||||
## 6.2 Preferences
|
||||
Use existing `AppSettings` for:
|
||||
- `adhanEnabled` (per prayer)
|
||||
- `iqamahOffset` (per prayer)
|
||||
|
||||
Add new fields (either in `AppSettings` or separate box):
|
||||
- `alertsEnabled` (global)
|
||||
- `inboxEnabled` (global)
|
||||
- `streakRiskEnabled`
|
||||
- `dailyChecklistReminderEnabled`
|
||||
- `weeklySummaryEnabled`
|
||||
- `quietHoursStart` / `quietHoursEnd`
|
||||
- `maxNonPrayerPushPerDay` (default 2)
|
||||
|
||||
## 6.3 Badge Source
|
||||
- Bell badge count = unread inbox count only.
|
||||
- Do not include "already fired system push" count.
|
||||
|
||||
## 7. Local Notification Spec (Current + Next)
|
||||
## 7.1 Channels
|
||||
- `adhan_channel`: max importance, sound on.
|
||||
- `iqamah_channel`: high importance, sound on.
|
||||
- Later:
|
||||
- `habit_channel` (streak/checklist)
|
||||
- `system_channel` (permission/location)
|
||||
|
||||
## 7.2 Permission Strategy
|
||||
- Request `POST_NOTIFICATIONS` and exact alarm capability only when needed.
|
||||
- If denied:
|
||||
- create Inbox warning item
|
||||
- show non-blocking UI notice
|
||||
|
||||
## 7.3 Scheduling Rules
|
||||
- Keep 2-day rolling window (today + tomorrow).
|
||||
- Resync on:
|
||||
- app start
|
||||
- city change
|
||||
- prayer settings change
|
||||
- adzan toggle change
|
||||
- daily boundary change (00:05 local)
|
||||
- Deduplicate by key: `cityId + date + prayer + kind(adhan/iqamah)`.
|
||||
|
||||
## 8. UX Specification
|
||||
## 8.1 Bell Icon
|
||||
- Tap action: open Notification Center.
|
||||
- Badge: unread inbox count.
|
||||
- Long-press (optional): quick actions
|
||||
- toggle `Alarm Sholat`
|
||||
- open Iqamah settings
|
||||
- sync now
|
||||
|
||||
## 8.2 Notification Center Screen
|
||||
Tabs:
|
||||
- `Alarm`
|
||||
- recent fired alerts (read-only timeline, optional v2)
|
||||
- `Pesan`
|
||||
- unread/read list with filters (`Semua`, `Belum Dibaca`, `Sistem`)
|
||||
|
||||
Item actions:
|
||||
- Tap: open deeplink target.
|
||||
- Swipe: mark read/unread.
|
||||
- Optional: pin important item.
|
||||
|
||||
## 8.3 Copy Guidelines
|
||||
- Urgent: concise, action-first.
|
||||
- Inbox: context-rich but short (max ~120 chars body preview).
|
||||
- Prayer push examples:
|
||||
- `Adzan • Subuh`
|
||||
- `Waktu sholat Subuh telah masuk.`
|
||||
|
||||
## 9. Deep Link Map
|
||||
- Permission blocked -> `/settings` (notifications section)
|
||||
- Location disabled -> `/settings` or location setup section
|
||||
- Streak risk Tilawah -> `/quran`
|
||||
- Streak risk Dzikir -> `/tools/dzikir`
|
||||
- Weekly summary -> `/laporan`
|
||||
- Prayer event details -> `/` (Beranda)
|
||||
|
||||
## 10. Analytics
|
||||
Track:
|
||||
- `notif_push_scheduled`
|
||||
- `notif_push_fired`
|
||||
- `notif_push_opened`
|
||||
- `notif_inbox_created`
|
||||
- `notif_inbox_opened`
|
||||
- `notif_mark_read`
|
||||
- `notif_settings_changed`
|
||||
- `notif_permission_denied`
|
||||
|
||||
Dimensions:
|
||||
- `event_type`
|
||||
- `channel` (`push|inbox|both`)
|
||||
- `city_id`
|
||||
- `simple_mode`
|
||||
|
||||
## 11. Rollout Plan
|
||||
## Phase 1 (Now)
|
||||
- Stabilize prayer alerts (Adzan + Iqamah) local scheduling.
|
||||
- Working sound toggle in hero card and settings.
|
||||
- Permission and exact-alarm checks.
|
||||
|
||||
## Phase 2
|
||||
- Build Notification Center page + unread badge.
|
||||
- Add inbox persistence and read/unread actions.
|
||||
- Add system warning messages to inbox.
|
||||
|
||||
## Phase 3
|
||||
- Add non-prayer reminders (checklist, streak risk, weekly summary).
|
||||
- Add cooldown/frequency cap and quiet hours.
|
||||
|
||||
## Phase 4
|
||||
- Remote push integration (FCM/APNs).
|
||||
- Server-defined campaigns/content updates.
|
||||
|
||||
## 12. Acceptance Criteria
|
||||
- Toggling adzan off removes all pending adzan/iqamah notifications.
|
||||
- Toggling adzan on schedules valid upcoming notifications for enabled prayers.
|
||||
- Changing iqamah minutes updates future scheduled iqamah alerts immediately.
|
||||
- Bell opens Notification Center (after Phase 2).
|
||||
- Unread badge count reflects inbox unread only.
|
||||
- No duplicate notifications for same prayer/time/city.
|
||||
|
||||
## 13. QA Checklist
|
||||
- Android 13/14: permission denied -> graceful fallback.
|
||||
- Exact alarm disabled -> user warning path works.
|
||||
- Timezone changes -> schedule recalculates correctly.
|
||||
- Day rollover -> next-day notifications still present.
|
||||
- City changes -> old city schedules removed; new city schedules added.
|
||||
- Device reboot (future): optional reschedule receiver if needed.
|
||||
|
||||
## 14. Open Decisions
|
||||
1. Should adzan events be mirrored into inbox by default?
|
||||
2. Should quiet hours suppress non-prayer push only, or all push except adzan?
|
||||
3. Should `Alarm` tab show only fired events or also pending schedule preview?
|
||||
81
dzikir-display-mode-ux-brief.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Dzikir Display Mode UX Brief
|
||||
|
||||
## 1) Objective
|
||||
Provide two complementary experiences for Dzikir:
|
||||
|
||||
- **Daftar (Baris)** for fast scanning and jumping between items.
|
||||
- **Fokus (Slide)** for one-item focus with consistent thumb reach and counting flow.
|
||||
|
||||
This mode applies to all Dzikir tabs: **Pagi**, **Petang**, and **Sesudah Shalat**.
|
||||
|
||||
## 2) Settings Specification
|
||||
|
||||
Section name in Settings: **Tampilan Dzikir**
|
||||
|
||||
| Label | Type | Options | Default | Visibility |
|
||||
|---|---|---|---|---|
|
||||
| `Mode Tampilan Dzikir` | Segmented | `Daftar (Baris)` / `Fokus (Slide)` | `Daftar (Baris)` | Always |
|
||||
| `Posisi Tombol Hitung` | Segmented | `Pill Bawah (Disarankan)` / `Bulat Kanan Bawah` | `Pill Bawah (Disarankan)` | Only in `Fokus (Slide)` |
|
||||
| `Lanjut Otomatis Saat Target Tercapai` | Switch | `On/Off` | `On` | Only in `Fokus (Slide)` |
|
||||
| `Getaran Saat Hitung` | Switch | `On/Off` | `On` | Always |
|
||||
|
||||
## 3) Interaction Rules
|
||||
|
||||
### A. Mode: Daftar (Baris)
|
||||
- Keep current row-based list and per-row counter pattern.
|
||||
- Users can scan, jump, and increment any row directly.
|
||||
- Counter behavior remains per item, per day.
|
||||
|
||||
### B. Mode: Fokus (Slide)
|
||||
- Display exactly **one dzikir item per slide**.
|
||||
- Horizontal swipe moves between dzikir items.
|
||||
- Counter button is fixed in one location (based on selected button position).
|
||||
- Top area displays progress: `Item X dari Y`.
|
||||
- Tapping counter increments by `+1` until target.
|
||||
- When target reached:
|
||||
- Mark item as complete.
|
||||
- If `Lanjut Otomatis... = On`, move to next slide automatically (except last item).
|
||||
|
||||
## 4) Button Placement Recommendation
|
||||
|
||||
Primary recommendation:
|
||||
|
||||
- **Pill Bawah (Disarankan)** as default in Focus mode.
|
||||
|
||||
Reason:
|
||||
- Better one-handed ergonomics.
|
||||
- Consistent location improves counting rhythm.
|
||||
- Larger tap target lowers miss taps while reciting.
|
||||
|
||||
Optional style:
|
||||
- **Bulat Kanan Bawah** for users preferring minimal visual footprint.
|
||||
|
||||
## 5) Data & State Behavior
|
||||
|
||||
- Counter data is shared across modes (switching mode must not reset progress).
|
||||
- Existing daily tracking logic remains unchanged.
|
||||
- Switching mode keeps current tab (`Pagi/Petang/Sesudah Shalat`) intact.
|
||||
- Completed state must be reflected identically in both modes.
|
||||
|
||||
## 6) Completion & Feedback UX
|
||||
|
||||
- Counter states: `normal` and `completed`.
|
||||
- Completed label example: `Selesai`.
|
||||
- Last item completion feedback:
|
||||
- Show subtle confirmation message: `Semua dzikir pada tab ini selesai`.
|
||||
- Empty or missing data:
|
||||
- Show friendly empty state, never blank screen.
|
||||
|
||||
## 7) Default Product Decision
|
||||
|
||||
- App default: **Daftar (Baris)** for broad familiarity.
|
||||
- Advanced/focus users can enable **Fokus (Slide)**.
|
||||
- In Focus mode, default button placement: **Pill Bawah (Disarankan)**.
|
||||
|
||||
## 8) Success Criteria
|
||||
|
||||
- Users can switch between modes without losing count progress.
|
||||
- Focus mode reduces hand travel for repeated taps.
|
||||
- Both modes remain consistent across all Dzikir tabs.
|
||||
- No behavioral mismatch between count target, completion state, and progress indicator.
|
||||
|
||||
173
hugeicons-migration-spec.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# HugeIcons Migration Spec
|
||||
|
||||
## Scope
|
||||
- Replace current Lucide icon usage with HugeIcons targets.
|
||||
- Prioritize Islamic-context icons first (bottom nav, Beranda, Lainnya, Qibla, Qur'an entry points).
|
||||
- Keep UI behavior unchanged; this spec is icon-only.
|
||||
|
||||
## Inventory Summary (Current State)
|
||||
- Total Lucide references: `158`
|
||||
- Unique Lucide symbols: `72`
|
||||
- Non-Lucid icon reference: `Icons.arrow_back` in `quran_murattal_screen.dart`
|
||||
|
||||
Top icon-heavy files:
|
||||
1. `lib/features/settings/presentation/settings_screen.dart`
|
||||
2. `lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
3. `lib/features/quran/presentation/quran_reading_screen.dart`
|
||||
4. `lib/features/checklist/presentation/checklist_screen.dart`
|
||||
5. `lib/features/tools/presentation/tools_screen.dart`
|
||||
|
||||
## Recommended HugeIcons Style Policy
|
||||
- `Stroke Rounded` for navigation/system icons.
|
||||
- `Duotone Rounded` for high-emphasis feature cards.
|
||||
- Islamic-specific icons use explicit semantic slugs (Qur'an, Tasbih, Kaaba, Dua, Mosque).
|
||||
|
||||
## Canonical Mapping (Lucide -> HugeIcons Candidate)
|
||||
Notes:
|
||||
- `Confidence` = how certain the target slug/semantic fit is.
|
||||
- `High` means already validated semantically on HugeIcons.
|
||||
- `Medium` means strong candidate but should be quickly verified in final implementation pass.
|
||||
|
||||
| Lucide | HugeIcons candidate | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| `home` | `home-01-stroke-rounded` | High | Bottom nav Beranda |
|
||||
| `calendar` | `calendar-01` | High | Bottom nav Jadwal |
|
||||
| `bookOpen` | `quran-02-solid-sharp` (feature), `book-open-01-stroke-rounded` (generic) | High | Use Qur'an variant where context is Qur'an |
|
||||
| `sparkles` | `tasbih` (Dzikir), `sparkles`-family for generic | High/Medium | Dzikir should use Tasbih |
|
||||
| `wand2` | `magic-wand-01-duotone-rounded` | High | Bottom nav Lainnya |
|
||||
| `listChecks` | `check-list` | High | Ibadah list |
|
||||
| `barChart3` | `bar-chart-03-stroke-rounded` | High | Laporan |
|
||||
| `headphones` | `headset-solid-rounded` | Medium | Murattal/audio |
|
||||
| `compass` | `compass-duotone-rounded` | High | Qibla entry/action |
|
||||
| `library` | `books-01-solid-standard` | High | Hadits card |
|
||||
| `heart` | `dua-solid-sharp` (Doa context), `heart`-family (generic) | High/Medium | Prefer Dua icon for Doa feature |
|
||||
| `mapPin` | `mosque-location` (Qibla/location context), `location-01` (generic) | High/Medium | Prefer mosque-location in Islamic context |
|
||||
| `locate` | `compass-duotone-rounded` or `location-focus`-family | Medium | Qibla live-state |
|
||||
| `locateOff` | `location-off`-family | Medium | Qibla sensor-off |
|
||||
| `bookmark` | `bookmark-02-stroke-rounded` | Medium | Qur'an/bookmarks |
|
||||
| `pin` | `pin-location`-family | Medium | Last read |
|
||||
| `search` | `search-01-stroke-rounded` | Medium | Global search |
|
||||
| `refreshCw` | `refresh`-family | Medium | Reload actions |
|
||||
| `settings` | `settings-02-stroke-rounded` | Medium | Global settings |
|
||||
| `settings2` | `settings-02-stroke-rounded` | Medium | Keep one settings variant |
|
||||
| `bell` | `notification`-family | Medium | Notification |
|
||||
| `checkCircle2` | `checkmark-circle`-family | Medium | Completion status |
|
||||
| `circle` | `circle`-family | Medium | Unchecked state |
|
||||
| `check` | `tick`-family | Medium | Confirmed state |
|
||||
| `chevronLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
|
||||
| `chevronRight` | `arrow-right-01-stroke-rounded` | Medium | Settings rows |
|
||||
| `chevronDown` | `arrow-down-01-stroke-rounded` | Medium | Dropdown |
|
||||
| `chevronUp` | `arrow-up-01-stroke-rounded` | Medium | Expand/collapse |
|
||||
| `arrowLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
|
||||
| `arrowRight` | `arrow-right-01-stroke-rounded` | Medium | Forward |
|
||||
| `clock` | `time`-family | Medium | Prayer/meta time |
|
||||
| `sunrise` | `sunrise`-family | Medium | Prayer icon |
|
||||
| `sun` | `sun`-family | Medium | Prayer icon |
|
||||
| `cloudSun` | `cloud-sun`-family | Medium | Prayer icon |
|
||||
| `sunset` | `sunset`-family | Medium | Prayer icon |
|
||||
| `moon` | `moon`-family | Medium | Prayer/night |
|
||||
| `volume2` | `volume-high`-family | Medium | Audio toggle |
|
||||
| `user` | `user-03-stroke-rounded` | Medium | Profile/author |
|
||||
| `quote` | `quote`-family | Medium | Ayat quote panel |
|
||||
| `fingerprint` | `fingerprint-01` | Medium | Dzikir tap counter |
|
||||
| `inbox` | `inbox-01` | Medium | Empty state |
|
||||
| `wifiOff` | `wifi-off-01` | Medium | Offline state |
|
||||
| `star` | `star`-family | Medium | Ratings/highlight |
|
||||
| `building` | `mosque-01` (Islamic), `building`-family (generic) | Medium | Prefer mosque if religious place context |
|
||||
| `minusCircle` | `minus-sign-circle`-family | Medium | Counter controls |
|
||||
| `plusCircle` | `plus-sign-circle`-family | Medium | Counter controls |
|
||||
| `moonStar` | `moon-stars`-family | Medium | Night checklist |
|
||||
| `trendingUp` | `chart-up`-family | Medium | Trend KPI |
|
||||
| `history` | `history`-family | Medium | Reports history |
|
||||
| `share2` | `share`-family | Medium | Share action |
|
||||
| `play` | `play`-family | Medium | Media |
|
||||
| `pause` | `pause`-family | Medium | Media |
|
||||
| `stopCircle` | `stop-circle`-family | Medium | Media/record stop |
|
||||
| `playCircle` | `play-circle`-family | Medium | Media/record play |
|
||||
| `square` | `stop-square`-family | Medium | Media |
|
||||
| `skipBack` | `skip-back`-family | Medium | Media |
|
||||
| `skipForward` | `skip-forward`-family | Medium | Media |
|
||||
| `shuffle` | `shuffle`-family | Medium | Media |
|
||||
| `listMusic` | `playlist`-family | Medium | Media list |
|
||||
| `music` | `music-note`-family | Medium | Media indicator |
|
||||
| `brain` | `brain`-family | Medium | Hafalan mode |
|
||||
| `gem` | `diamond`/`gem`-family | Medium | Highlight badge |
|
||||
| `flag` | `flag`-family | Medium | Marker |
|
||||
| `trash2` | `delete`-family | Medium | Remove bookmark |
|
||||
| `layoutDashboard` | `dashboard-square`-family | Medium | Settings |
|
||||
| `timer` | `timer`-family | Medium | Timing settings |
|
||||
| `type` | `text`-family | Medium | Typography settings |
|
||||
| `vibrate` | `vibration`-family | Medium | Haptic settings |
|
||||
| `pencil` | `edit`-family | Medium | Edit profile |
|
||||
| `info` | `information-circle`-family | Medium | Info |
|
||||
| `logOut` | `logout-01`-family | Medium | Logout |
|
||||
| `languages` | `language`-family | Medium | Qur'an enrichment language |
|
||||
|
||||
## Screen-Level Migration Targets
|
||||
|
||||
### P0: Bottom Navigation
|
||||
File: `lib/core/widgets/bottom_nav_bar.dart`
|
||||
- `home` -> `home-01-stroke-rounded`
|
||||
- `calendar` -> `calendar-01`
|
||||
- `bookOpen` -> `quran-02-solid-sharp`
|
||||
- `sparkles` -> `tasbih`
|
||||
- `wand2` -> `magic-wand-01-duotone-rounded`
|
||||
- `listChecks` -> `check-list`
|
||||
- `barChart3` -> `bar-chart-03-stroke-rounded`
|
||||
|
||||
### P0: Beranda Quick Access + Header Context
|
||||
File: `lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
- Qur'an card: `bookOpen` -> `quran-02-solid-sharp`
|
||||
- Murattal: `headphones` -> `headset-solid-rounded`
|
||||
- Dzikir: `sparkles` -> `tasbih`
|
||||
- Doa: `heart` -> `dua-solid-sharp`
|
||||
- Hadits: `library` -> `books-01-solid-standard`
|
||||
- Qibla button/location: `compass/mapPin` -> `compass-duotone-rounded` + `mosque-location`
|
||||
- Prayer state icons (`sunrise/sun/cloudSun/sunset/moon`) -> matching HugeIcons weather/time family
|
||||
|
||||
### P0: Lainnya (Tools)
|
||||
File: `lib/features/tools/presentation/tools_screen.dart`
|
||||
- Same feature mapping as Beranda quick access.
|
||||
- App-bar/system icons remain generic HugeIcons stroke-rounded (`bell`, `settings`, `share`).
|
||||
|
||||
### P0: Qibla Screen
|
||||
File: `lib/features/qibla/presentation/qibla_screen.dart`
|
||||
- Back: `arrowLeft` -> `arrow-left-01-stroke-rounded`
|
||||
- Live state: `locate/locateOff` -> location-focus/location-off family
|
||||
- Location marker: `mapPin` -> `mosque-location` (preferred) or `location-01`
|
||||
|
||||
### P1: Qur'an, Dzikir, Doa, Hadits
|
||||
- Qur'an family files: prioritize Qur'an/bookmark/media semantics.
|
||||
- Dzikir screen: `fingerprint` may remain if preferred UX metaphor; alternative `tasbih` for stronger Islamic tone.
|
||||
- Doa/Hadits list screens: keep simple system icon replacements for `search`, `refresh`.
|
||||
|
||||
### P2: Settings / Checklist / Laporan
|
||||
- Mostly generic system icons; migrate to HugeIcons equivalents with minimal semantic risk.
|
||||
|
||||
## Proposed Implementation Contract
|
||||
1. Introduce wrapper layer (single source of truth): `lib/app/icons/app_icons.dart`
|
||||
2. Keep semantic names stable in app code (`AppIcons.quran`, `AppIcons.dzikir`, `AppIcons.qibla`, etc.).
|
||||
3. Map `AppIcons.*` to HugeIcons package symbols.
|
||||
4. Perform migration by feature slices (P0 -> P2), with visual QA after each slice.
|
||||
|
||||
## QA Checklist
|
||||
- Bottom nav recognizability retained at 24dp.
|
||||
- Active/inactive contrast unchanged.
|
||||
- No icon clipping at 20/24dp sizes.
|
||||
- Islamic features feel more explicit (Qur'an, Dzikir, Doa, Qibla).
|
||||
- Dark/light mode visual consistency maintained.
|
||||
|
||||
## Reference Links (HugeIcons)
|
||||
- https://hugeicons.com/icon/quran-02-solid-sharp
|
||||
- https://hugeicons.com/icon/tasbih
|
||||
- https://hugeicons.com/icon/mosque-location
|
||||
- https://hugeicons.com/icon/kaaba-01-stroke-standard
|
||||
- https://hugeicons.com/icon/dua-solid-sharp
|
||||
- https://hugeicons.com/icon/books-01-solid-standard
|
||||
- https://hugeicons.com/icon/home-01-stroke-rounded
|
||||
- https://hugeicons.com/icon/calendar-01
|
||||
- https://hugeicons.com/icon/check-list
|
||||
- https://hugeicons.com/icon/magic-wand-01-duotone-rounded
|
||||
- https://hugeicons.com/icon/bar-chart-03-stroke-rounded
|
||||
- https://hugeicons.com/icon/compass-duotone-rounded
|
||||
- https://hugeicons.com/icon/headset-solid-rounded
|
||||
34
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
2
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1,6 +1,6 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter
|
||||
FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary
|
||||
FLUTTER_ROOT=/opt/homebrew/share/flutter
|
||||
FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib/main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
|
||||
2
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary"
|
||||
export "FLUTTER_ROOT=/opt/homebrew/share/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
|
||||
620
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,620 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
16
ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -60,6 +60,12 @@
|
||||
@import package_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<share_plus/FPPSharePlusPlugin.h>)
|
||||
#import <share_plus/FPPSharePlusPlugin.h>
|
||||
#else
|
||||
@import share_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
|
||||
#import <sqflite_darwin/SqflitePlugin.h>
|
||||
#else
|
||||
@@ -84,6 +90,7 @@
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
}
|
||||
|
||||
74
ios/Runner/Info.plist
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Jamshalat Diary</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>jamshalat_diary</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,16 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show ViewFocusEvent, ViewFocusState;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/providers/theme_provider.dart';
|
||||
import '../features/dashboard/data/prayer_times_provider.dart';
|
||||
import 'router.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
|
||||
class App extends ConsumerWidget {
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
Timer? _midnightResyncTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
});
|
||||
_scheduleMidnightResync();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_midnightResyncTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
// Resync stale pressed-key state to avoid repeated KeyDown assertions.
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
}
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.invalidate(prayerTimesProvider);
|
||||
unawaited(ref.read(prayerTimesProvider.future));
|
||||
_scheduleMidnightResync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeViewFocus(ViewFocusEvent event) {
|
||||
if (event.state == ViewFocusState.focused) {
|
||||
HardwareKeyboard.instance.syncKeyboardState();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleMidnightResync() {
|
||||
_midnightResyncTimer?.cancel();
|
||||
final now = DateTime.now();
|
||||
final nextRun = DateTime(now.year, now.month, now.day, 0, 5).isAfter(now)
|
||||
? DateTime(now.year, now.month, now.day, 0, 5)
|
||||
: DateTime(now.year, now.month, now.day + 1, 0, 5);
|
||||
final delay = nextRun.difference(now);
|
||||
_midnightResyncTimer = Timer(delay, () {
|
||||
ref.invalidate(prayerTimesProvider);
|
||||
unawaited(ref.read(prayerTimesProvider.future));
|
||||
_scheduleMidnightResync();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
|
||||
119
lib/app/icons/app_icons.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hugeicons/hugeicons.dart';
|
||||
|
||||
@immutable
|
||||
class AppIconGlyph {
|
||||
const AppIconGlyph.material(this.material) : huge = null;
|
||||
const AppIconGlyph.huge(this.huge) : material = null;
|
||||
|
||||
final IconData? material;
|
||||
final List<List<dynamic>>? huge;
|
||||
}
|
||||
|
||||
class AppIcon extends StatelessWidget {
|
||||
const AppIcon({
|
||||
super.key,
|
||||
required this.glyph,
|
||||
this.color,
|
||||
this.size,
|
||||
this.strokeWidth,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final AppIconGlyph glyph;
|
||||
final Color? color;
|
||||
final double? size;
|
||||
final double? strokeWidth;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final huge = glyph.huge;
|
||||
if (huge != null) {
|
||||
return HugeIcon(
|
||||
icon: huge,
|
||||
color: color,
|
||||
size: size,
|
||||
strokeWidth: strokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
glyph.material,
|
||||
color: color,
|
||||
size: size,
|
||||
semanticLabel: semanticLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppIcons {
|
||||
const AppIcons._();
|
||||
|
||||
static const AppIconGlyph home =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedHome01);
|
||||
static const AppIconGlyph calendar =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCalendar01);
|
||||
static const AppIconGlyph quran =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran02);
|
||||
static const AppIconGlyph dzikir =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedTasbih);
|
||||
static const AppIconGlyph lainnya =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedGridView);
|
||||
static const AppIconGlyph ibadah =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckList);
|
||||
static const AppIconGlyph laporan =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedChart01);
|
||||
|
||||
static const AppIconGlyph murattal =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedHeadset);
|
||||
static const AppIconGlyph qibla =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCompass);
|
||||
static const AppIconGlyph doa = AppIconGlyph.huge(HugeIcons.strokeRoundedDua);
|
||||
static const AppIconGlyph hadits =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedBooks01);
|
||||
static const AppIconGlyph quranEnrichment =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran01);
|
||||
|
||||
static const AppIconGlyph notification =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedNotification03);
|
||||
static const AppIconGlyph settings =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedSettings02);
|
||||
static const AppIconGlyph share =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedShare01);
|
||||
static const AppIconGlyph themeMoon =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMoon02);
|
||||
static const AppIconGlyph themeSun =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedSun02);
|
||||
static const AppIconGlyph checkCircle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckmarkCircle02);
|
||||
static const AppIconGlyph circle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedCircle);
|
||||
static const AppIconGlyph musicNote =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMusicNote01);
|
||||
static const AppIconGlyph shuffle =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedShuffle);
|
||||
static const AppIconGlyph previousTrack =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPrevious);
|
||||
static const AppIconGlyph nextTrack =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedNext);
|
||||
static const AppIconGlyph play =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPlay);
|
||||
static const AppIconGlyph pause =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPause);
|
||||
static const AppIconGlyph playlist =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedPlaylist01);
|
||||
static const AppIconGlyph user =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedUser03);
|
||||
static const AppIconGlyph arrowDown =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowDown01);
|
||||
|
||||
static const AppIconGlyph backArrow =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowLeft01);
|
||||
static const AppIconGlyph location =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedMosqueLocation);
|
||||
static const AppIconGlyph locationActive =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedLocation01);
|
||||
static const AppIconGlyph locationOffline =
|
||||
AppIconGlyph.huge(HugeIcons.strokeRoundedLocationOffline01);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../data/local/hive_boxes.dart';
|
||||
@@ -11,11 +13,15 @@ import '../features/checklist/presentation/checklist_screen.dart';
|
||||
import '../features/laporan/presentation/laporan_screen.dart';
|
||||
import '../features/tools/presentation/tools_screen.dart';
|
||||
import '../features/dzikir/presentation/dzikir_screen.dart';
|
||||
import '../features/doa/presentation/doa_screen.dart';
|
||||
import '../features/hadits/presentation/hadits_screen.dart';
|
||||
import '../features/qibla/presentation/qibla_screen.dart';
|
||||
import '../features/quran/presentation/quran_screen.dart';
|
||||
import '../features/quran/presentation/quran_reading_screen.dart';
|
||||
import '../features/quran/presentation/quran_murattal_screen.dart';
|
||||
import '../features/quran/presentation/quran_bookmarks_screen.dart';
|
||||
import '../features/quran/presentation/quran_enrichment_screen.dart';
|
||||
import '../features/notifications/presentation/notification_center_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
|
||||
/// Navigation key for the shell navigator (bottom-nav screens).
|
||||
@@ -30,12 +36,16 @@ final GoRouter appRouter = GoRouter(
|
||||
// ── Shell route (bottom nav persists) ──
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) => _ScaffoldWithNav(child: child),
|
||||
pageBuilder: (context, state, child) => NoTransitionPage(
|
||||
key: ValueKey<String>('shell-${state.pageKey.value}'),
|
||||
child: _ScaffoldWithNav(child: child),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: DashboardScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const DashboardScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -47,26 +57,30 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: '/imsakiyah',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ImsakiyahScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ImsakiyahScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/checklist',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ChecklistScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ChecklistScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/laporan',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: LaporanScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const LaporanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tools',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ToolsScreen(),
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ToolsScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -79,6 +93,11 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
@@ -89,8 +108,10 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
|
||||
final startVerse = int.tryParse(
|
||||
state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(
|
||||
surahId: surahId, initialVerse: startVerse);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -99,9 +120,10 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final qariId = state.uri.queryParameters['qariId'];
|
||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
||||
final autoplay =
|
||||
state.uri.queryParameters['autoplay'] == 'true';
|
||||
return QuranMurattalScreen(
|
||||
surahId: surahId,
|
||||
surahId: surahId,
|
||||
initialQariId: qariId,
|
||||
autoPlay: autoplay,
|
||||
);
|
||||
@@ -116,28 +138,49 @@ final GoRouter appRouter = GoRouter(
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QiblaScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'doa',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const DoaScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'hadits',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const HaditsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Simple Mode Tab: Zikir
|
||||
GoRoute(
|
||||
path: '/dzikir',
|
||||
builder: (context, state) => const DzikirScreen(isSimpleModeTab: true),
|
||||
builder: (context, state) =>
|
||||
const DzikirScreen(isSimpleModeTab: true),
|
||||
),
|
||||
// Simple Mode Tab: Tilawah
|
||||
GoRoute(
|
||||
path: '/quran',
|
||||
builder: (context, state) => const QuranScreen(isSimpleModeTab: true),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
builder: (context, state) =>
|
||||
const QuranEnrichmentScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
builder: (context, state) => const QuranBookmarksScreen(),
|
||||
builder: (context, state) =>
|
||||
const QuranBookmarksScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':surahId',
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse, isSimpleModeTab: true);
|
||||
final startVerse =
|
||||
int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(
|
||||
surahId: surahId,
|
||||
initialVerse: startVerse,
|
||||
isSimpleModeTab: true);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -146,9 +189,10 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final qariId = state.uri.queryParameters['qariId'];
|
||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
||||
final autoplay =
|
||||
state.uri.queryParameters['autoplay'] == 'true';
|
||||
return QuranMurattalScreen(
|
||||
surahId: surahId,
|
||||
surahId: surahId,
|
||||
initialQariId: qariId,
|
||||
autoPlay: autoplay,
|
||||
isSimpleModeTab: true,
|
||||
@@ -159,9 +203,23 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/doa',
|
||||
builder: (context, state) => const DoaScreen(isSimpleModeTab: true),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/hadits',
|
||||
builder: (context, state) =>
|
||||
const HaditsScreen(isSimpleModeTab: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Settings (pushed, no bottom nav) ──
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const NotificationCenterScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
@@ -171,11 +229,31 @@ final GoRouter appRouter = GoRouter(
|
||||
);
|
||||
|
||||
/// Scaffold wrapper that provides the persistent bottom nav bar.
|
||||
class _ScaffoldWithNav extends StatelessWidget {
|
||||
class _ScaffoldWithNav extends StatefulWidget {
|
||||
const _ScaffoldWithNav({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_ScaffoldWithNav> createState() => _ScaffoldWithNavState();
|
||||
}
|
||||
|
||||
class _ScaffoldWithNavState extends State<_ScaffoldWithNav> {
|
||||
DateTime? _lastBackPressedAt;
|
||||
|
||||
bool _shouldHideBottomNav({
|
||||
required bool isSimpleMode,
|
||||
required String path,
|
||||
}) {
|
||||
if (!isSimpleMode) return false;
|
||||
if (path == '/dzikir') return true;
|
||||
if (!path.startsWith('/quran/')) return false;
|
||||
|
||||
final tail = path.substring('/quran/'.length);
|
||||
if (tail == 'bookmarks' || tail == 'enrichment') return false;
|
||||
return !tail.contains('/');
|
||||
}
|
||||
|
||||
/// Maps route locations to bottom nav indices.
|
||||
int _currentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
@@ -184,9 +262,13 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
|
||||
if (isSimpleMode) {
|
||||
if (location.startsWith('/imsakiyah')) return 1;
|
||||
if (location.startsWith('/quran') && !location.contains('/murattal')) return 2;
|
||||
if (location.contains('/murattal')) return 3;
|
||||
if (location.startsWith('/dzikir')) return 4;
|
||||
if (location.startsWith('/quran')) return 2;
|
||||
if (location.startsWith('/dzikir')) return 3;
|
||||
if (location.startsWith('/tools') ||
|
||||
location.startsWith('/doa') ||
|
||||
location.startsWith('/hadits')) {
|
||||
return 4;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
if (location.startsWith('/imsakiyah')) return 1;
|
||||
@@ -213,10 +295,10 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
context.go('/quran');
|
||||
break;
|
||||
case 3:
|
||||
context.push('/quran/1/murattal');
|
||||
context.go('/dzikir');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/dzikir');
|
||||
context.go('/tools');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -240,16 +322,97 @@ class _ScaffoldWithNav extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isMainShellRoute({
|
||||
required bool isSimpleMode,
|
||||
required String path,
|
||||
}) {
|
||||
if (isSimpleMode) {
|
||||
return path == '/' ||
|
||||
path == '/imsakiyah' ||
|
||||
path == '/quran' ||
|
||||
path == '/dzikir' ||
|
||||
path == '/tools';
|
||||
}
|
||||
|
||||
return path == '/' ||
|
||||
path == '/imsakiyah' ||
|
||||
path == '/checklist' ||
|
||||
path == '/laporan' ||
|
||||
path == '/tools';
|
||||
}
|
||||
|
||||
Future<void> _handleMainRouteBack(
|
||||
BuildContext context, {
|
||||
required String path,
|
||||
}) async {
|
||||
if (path != '/') {
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final pressedRecently = _lastBackPressedAt != null &&
|
||||
now.difference(_lastBackPressedAt!) <= const Duration(seconds: 2);
|
||||
|
||||
if (pressedRecently) {
|
||||
await SystemNavigator.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBackPressedAt = now;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tekan sekali lagi untuk keluar'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||
builder: (context, box, _) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentIndex: _currentIndex(context),
|
||||
onTap: (i) => _onTap(context, i),
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final path = GoRouterState.of(context).uri.path;
|
||||
final hideBottomNav = _shouldHideBottomNav(
|
||||
isSimpleMode: isSimpleMode,
|
||||
path: path,
|
||||
);
|
||||
final modeScopedChild = KeyedSubtree(
|
||||
key: ValueKey(
|
||||
'shell:$path:${isSimpleMode ? 'simple' : 'full'}',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
final pageBody = hideBottomNav
|
||||
? SafeArea(
|
||||
top: false,
|
||||
child: modeScopedChild,
|
||||
)
|
||||
: modeScopedChild;
|
||||
|
||||
final handleBackInShell =
|
||||
defaultTargetPlatform == TargetPlatform.android &&
|
||||
_isMainShellRoute(isSimpleMode: isSimpleMode, path: path);
|
||||
|
||||
return PopScope(
|
||||
canPop: !handleBackInShell,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop || !handleBackInShell) return;
|
||||
await _handleMainRouteBack(context, path: path);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: pageBody,
|
||||
bottomNavigationBar: hideBottomNav
|
||||
? null
|
||||
: AppBottomNavBar(
|
||||
currentIndex: _currentIndex(context),
|
||||
onTap: (i) => _onTap(context, i),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,29 +4,55 @@ import 'package:flutter/material.dart';
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Primary ──
|
||||
static const Color primary = Color(0xFF70DF20);
|
||||
static const Color onPrimary = Color(0xFF0A1A00);
|
||||
// ── Brand tokens: logo palette (teal + gold) ──
|
||||
static const Color brandTeal500 = Color(0xFF118A8D);
|
||||
static const Color brandTeal700 = Color(0xFF0C676A);
|
||||
static const Color brandTeal900 = Color(0xFF0A4447);
|
||||
|
||||
// ── Background ──
|
||||
static const Color backgroundLight = Color(0xFFF7F8F6);
|
||||
static const Color backgroundDark = Color(0xFF182111);
|
||||
static const Color brandGold200 = Color(0xFFF6DE96);
|
||||
static const Color brandGold300 = Color(0xFFE9C75B);
|
||||
static const Color brandGold400 = Color(0xFFD6A21D);
|
||||
static const Color brandGold700 = Color(0xFF8B6415);
|
||||
|
||||
// ── Theme base tokens ──
|
||||
static const Color backgroundLight = Color(0xFFF3F4F6);
|
||||
static const Color backgroundDark = Color(0xFF0F1217);
|
||||
|
||||
// ── Surface ──
|
||||
static const Color surfaceLight = Color(0xFFFFFFFF);
|
||||
static const Color surfaceDark = Color(0xFF1E2A14);
|
||||
static const Color surfaceLightElevated = Color(0xFFF9FAFB);
|
||||
|
||||
// ── Sage (secondary text / section labels) ──
|
||||
static const Color sage = Color(0xFF728764);
|
||||
static const Color surfaceDark = Color(0xFF171B22);
|
||||
static const Color surfaceDarkElevated = Color(0xFF1D222B);
|
||||
|
||||
// ── Cream (dividers, borders — light mode only) ──
|
||||
static const Color cream = Color(0xFFF2F4F0);
|
||||
static const Color textPrimaryLight = Color(0xFF1F2937);
|
||||
static const Color textPrimaryDark = Color(0xFFE8ECF2);
|
||||
static const Color textSecondaryLight = Color(0xFF6B7280);
|
||||
static const Color textSecondaryDark = Color(0xFF9AA4B2);
|
||||
|
||||
// ── Text ──
|
||||
static const Color textPrimaryLight = Color(0xFF1A2A0A);
|
||||
static const Color textPrimaryDark = Color(0xFFF2F4F0);
|
||||
static const Color textSecondaryLight = Color(0xFF64748B);
|
||||
static const Color textSecondaryDark = Color(0xFF94A3B8);
|
||||
// ── Compatibility aliases (existing UI references) ──
|
||||
static const Color primary = brandTeal500;
|
||||
static const Color onPrimary = Color(0xFFFFFFFF);
|
||||
static const Color sage = brandTeal700;
|
||||
static const Color cream = Color(0xFFE5E7EB);
|
||||
|
||||
// ── Luxury active-state tokens ──
|
||||
static const Color navActiveGold = brandGold400;
|
||||
static const Color navActiveGoldBright = brandGold300;
|
||||
static const Color navActiveGoldPale = brandGold200;
|
||||
static const Color navActiveGoldDeep = brandGold700;
|
||||
|
||||
static const Color navActiveSurfaceDark = surfaceDarkElevated;
|
||||
static const Color navActiveSurfaceLight = surfaceLight;
|
||||
|
||||
static const Color navGlowDark = Color(0x5CD6A21D);
|
||||
static const Color navGlowLight = Color(0x36D6A21D);
|
||||
static const Color navShadowLight = Color(0x1F0F172A);
|
||||
static const Color navStrokeNeutralDark = Color(0x33FFFFFF);
|
||||
static const Color navStrokeNeutralLight = Color(0x220F172A);
|
||||
|
||||
static const Color navEmbossHighlight = Color(0xE6FFFFFF);
|
||||
static const Color navEmbossShadow = Color(0x2B0F172A);
|
||||
static const Color navEmbossGoldShadow = Color(0x42B88912);
|
||||
|
||||
// ── Semantic ──
|
||||
static const Color errorLight = Color(0xFFEF4444);
|
||||
@@ -36,25 +62,29 @@ class AppColors {
|
||||
|
||||
// ── Convenience helpers for theme building ──
|
||||
|
||||
static ColorScheme get lightColorScheme => ColorScheme.light(
|
||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: brandTeal700,
|
||||
onPrimaryContainer: Colors.white,
|
||||
surface: surfaceLight,
|
||||
onSurface: textPrimaryLight,
|
||||
error: errorLight,
|
||||
onError: Colors.white,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
secondary: navActiveGold,
|
||||
onSecondary: brandGold700,
|
||||
);
|
||||
|
||||
static ColorScheme get darkColorScheme => ColorScheme.dark(
|
||||
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: brandTeal900,
|
||||
onPrimaryContainer: textPrimaryDark,
|
||||
surface: surfaceDark,
|
||||
onSurface: textPrimaryDark,
|
||||
error: errorDark,
|
||||
onError: Colors.black,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
secondary: navActiveGold,
|
||||
onSecondary: brandGold200,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Typography definitions from PRD §3.2.
|
||||
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
|
||||
/// Plus Jakarta Sans (bundled) for UI text.
|
||||
/// Scheherazade New (bundled) for Arabic/Quran text, with Uthman/KFGQPC fallback.
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
static const String _fontFamily = 'PlusJakartaSans';
|
||||
static const String _arabicFontFamily = 'ScheherazadeNew';
|
||||
static const List<String> _arabicFallbackFamilies = <String>[
|
||||
'UthmanTahaNaskh',
|
||||
'KFGQPCUthmanicHafs',
|
||||
'Amiri',
|
||||
'Noto Naskh Arabic',
|
||||
'Noto Sans Arabic',
|
||||
'Droid Arabic Naskh',
|
||||
'sans-serif',
|
||||
];
|
||||
|
||||
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
|
||||
static const TextTheme textTheme = TextTheme(
|
||||
@@ -52,19 +63,21 @@ class AppTextStyles {
|
||||
),
|
||||
);
|
||||
|
||||
// ── Arabic text styles (Amiri — bundled font) ──
|
||||
// ── Arabic text styles (Scheherazade New — bundled font) ──
|
||||
|
||||
static const TextStyle arabicBody = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontFamily: _arabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
height: 1.8,
|
||||
);
|
||||
|
||||
static const TextStyle arabicLarge = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontFamily: _arabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 2.2,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,17 +29,17 @@ class AppTheme {
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceLight,
|
||||
selectedItemColor: AppColors.primary,
|
||||
selectedItemColor: AppColors.navActiveGoldDeep,
|
||||
unselectedItemColor: AppColors.textSecondaryLight,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceLight,
|
||||
color: AppColors.surfaceLightElevated,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
side: const BorderSide(
|
||||
color: AppColors.cream,
|
||||
),
|
||||
),
|
||||
@@ -70,21 +70,21 @@ class AppTheme {
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceDark,
|
||||
selectedItemColor: AppColors.primary,
|
||||
selectedItemColor: AppColors.navActiveGold,
|
||||
unselectedItemColor: AppColors.textSecondaryDark,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceDark,
|
||||
color: AppColors.surfaceDarkElevated,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
color: AppColors.brandTeal500.withValues(alpha: 0.22),
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.surfaceDark,
|
||||
dividerColor: AppColors.surfaceDarkElevated,
|
||||
);
|
||||
}
|
||||
|
||||
11
lib/core/services/app_audio_player.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// Shared app-wide audio player.
|
||||
///
|
||||
/// `just_audio_background` supports only one `AudioPlayer` instance, so all
|
||||
/// playback surfaces should reuse this singleton.
|
||||
class AppAudioPlayer {
|
||||
AppAudioPlayer._();
|
||||
|
||||
static final AudioPlayer instance = AudioPlayer();
|
||||
}
|
||||
94
lib/core/widgets/arabic_text.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Arabic text widget that reacts to [AppSettings.arabicFontSize].
|
||||
///
|
||||
/// `baseFontSize` keeps per-screen visual hierarchy while still following
|
||||
/// global user preference from Settings.
|
||||
class ArabicText extends StatelessWidget {
|
||||
const ArabicText(
|
||||
this.data, {
|
||||
super.key,
|
||||
this.baseFontSize = 24,
|
||||
this.fontWeight = FontWeight.w400,
|
||||
this.height,
|
||||
this.color,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textDirection,
|
||||
this.fontStyle,
|
||||
this.letterSpacing,
|
||||
});
|
||||
|
||||
final String data;
|
||||
final double baseFontSize;
|
||||
final FontWeight fontWeight;
|
||||
final double? height;
|
||||
final Color? color;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextDirection? textDirection;
|
||||
final FontStyle? fontStyle;
|
||||
final double? letterSpacing;
|
||||
static const double _explicitLineHeightCompression = 0.9;
|
||||
static const double _defaultArabicLineHeight = 1.8;
|
||||
static const String _primaryArabicFontFamily = 'ScheherazadeNew';
|
||||
static const List<String> _arabicFallbackFamilies = <String>[
|
||||
'UthmanTahaNaskh',
|
||||
'KFGQPCUthmanicHafs',
|
||||
'Amiri',
|
||||
'Noto Naskh Arabic',
|
||||
'Noto Sans Arabic',
|
||||
'Droid Arabic Naskh',
|
||||
'sans-serif',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings)
|
||||
.listenable(keys: ['default']),
|
||||
builder: (_, box, __) {
|
||||
final preferredSize = box.get('default')?.arabicFontSize ?? 24.0;
|
||||
final adjustedSize = (baseFontSize + (preferredSize - 24.0))
|
||||
.clamp(12.0, 56.0)
|
||||
.toDouble();
|
||||
final effectiveHeight = height == null
|
||||
? _defaultArabicLineHeight
|
||||
: (height! * _explicitLineHeightCompression)
|
||||
.clamp(1.6, 2.35)
|
||||
.toDouble();
|
||||
|
||||
return Text(
|
||||
data,
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textDirection: textDirection,
|
||||
strutStyle: StrutStyle(
|
||||
fontFamily: _primaryArabicFontFamily,
|
||||
fontSize: adjustedSize,
|
||||
height: effectiveHeight,
|
||||
leading: 0.08,
|
||||
forceStrutHeight: true,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontFamily: _primaryArabicFontFamily,
|
||||
fontFamilyFallback: _arabicFallbackFamilies,
|
||||
fontSize: adjustedSize,
|
||||
fontWeight: fontWeight,
|
||||
height: effectiveHeight,
|
||||
color: color,
|
||||
fontStyle: fontStyle,
|
||||
letterSpacing: letterSpacing,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
686
lib/core/widgets/ayat_share_sheet.dart
Normal file
@@ -0,0 +1,686 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../core/widgets/arabic_text.dart';
|
||||
|
||||
String buildAyatShareText(Map<String, dynamic> ayat) {
|
||||
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||
? 'QS. $surahName: $verseNumber'
|
||||
: 'Ayat Hari Ini';
|
||||
|
||||
final parts = <String>[
|
||||
if (arabic.isNotEmpty) arabic,
|
||||
if (translation.isNotEmpty) '"$translation"',
|
||||
reference,
|
||||
'Dibagikan dari Jam Shalat Diary',
|
||||
];
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
Future<void> showAyatShareSheet(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> ayat,
|
||||
) async {
|
||||
final shareText = buildAyatShareText(ayat);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
Future<void> handleShareImage() async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
||||
try {
|
||||
final pngBytes = await _captureAyatShareCardPng(context, ayat);
|
||||
final file = await _writeAyatShareImage(pngBytes);
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text: 'Ayat Hari Ini',
|
||||
subject: 'Ayat Hari Ini',
|
||||
);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Gagal menyiapkan gambar ayat'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleShareText() async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
await Share.share(
|
||||
shareText,
|
||||
subject: 'Ayat Hari Ini',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleCopyText() async {
|
||||
await Clipboard.setData(ClipboardData(text: shareText));
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Teks ayat disalin ke clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Bagikan Ayat',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pilih cara tercepat untuk membagikan ayat hari ini.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AyatShareActionTile(
|
||||
icon: const Icon(
|
||||
LucideIcons.image,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Bagikan Gambar',
|
||||
subtitle: 'Kirim kartu ayat yang siap dibagikan',
|
||||
badge: 'Utama',
|
||||
onTap: handleShareImage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_AyatShareActionTile(
|
||||
icon: const AppIcon(
|
||||
glyph: AppIcons.share,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Bagikan Teks',
|
||||
subtitle: 'Kirim ayat dan terjemahan ke aplikasi lain',
|
||||
onTap: handleShareText,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_AyatShareActionTile(
|
||||
icon: const Icon(
|
||||
LucideIcons.copy,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: 'Salin Teks',
|
||||
subtitle: 'Simpan ke clipboard untuk ditempel manual',
|
||||
onTap: handleCopyText,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _captureAyatShareCardPng(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> ayat,
|
||||
) async {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
final boundaryKey = GlobalKey();
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final textDirection = Directionality.of(context);
|
||||
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => IgnorePointer(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Opacity(
|
||||
opacity: 0.01,
|
||||
child: MediaQuery(
|
||||
data: mediaQuery,
|
||||
child: Theme(
|
||||
data: theme,
|
||||
child: Directionality(
|
||||
textDirection: textDirection,
|
||||
child: UnconstrainedBox(
|
||||
constrainedAxis: Axis.horizontal,
|
||||
child: RepaintBoundary(
|
||||
key: boundaryKey,
|
||||
child: _AyatShareCard(
|
||||
ayat: ayat,
|
||||
isDark: theme.brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry);
|
||||
|
||||
try {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
final boundary = boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) {
|
||||
throw StateError('Ayat share card is not ready');
|
||||
}
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: 3);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) {
|
||||
throw StateError('Failed to encode ayat share card');
|
||||
}
|
||||
return byteData.buffer.asUint8List();
|
||||
} finally {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _writeAyatShareImage(Uint8List pngBytes) async {
|
||||
final directory = await Directory.systemTemp.createTemp('jamshalat_ayat_');
|
||||
final file = File('${directory.path}/ayat_hari_ini.png');
|
||||
await file.writeAsBytes(pngBytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
class _AyatShareCard extends StatelessWidget {
|
||||
const _AyatShareCard({
|
||||
required this.ayat,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> ayat;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||
? 'QS. $surahName: $verseNumber'
|
||||
: 'Ayat Hari Ini';
|
||||
final isLongArabic = arabic.length > 120;
|
||||
final isVeryLongArabic = arabic.length > 180;
|
||||
final isLongTranslation = translation.length > 140;
|
||||
final isVeryLongTranslation = translation.length > 220;
|
||||
final arabicFontSize = isVeryLongArabic
|
||||
? 22.0
|
||||
: isLongArabic
|
||||
? 24.0
|
||||
: 28.0;
|
||||
final arabicHeight = isVeryLongArabic
|
||||
? 1.55
|
||||
: isLongArabic
|
||||
? 1.62
|
||||
: 1.75;
|
||||
final translationFontSize = isVeryLongTranslation
|
||||
? 13.0
|
||||
: isLongTranslation
|
||||
? 14.0
|
||||
: 15.0;
|
||||
final translationHeight = isVeryLongTranslation ? 1.5 : 1.6;
|
||||
final verticalPadding = isVeryLongTranslation ? 22.0 : 24.0;
|
||||
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? const [
|
||||
Color(0xFF102028),
|
||||
Color(0xFF0F1217),
|
||||
Color(0xFF16343A),
|
||||
]
|
||||
: const [
|
||||
Color(0xFFF6FBFB),
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFEAF7F7),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.primary.withValues(alpha: isDark ? 0.24 : 0.12),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: CustomPaint(
|
||||
painter: _AyatFramePainter(isDark: isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -38,
|
||||
right: -34,
|
||||
child: Container(
|
||||
width: 116,
|
||||
height: 116,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -46,
|
||||
left: -28,
|
||||
child: Container(
|
||||
width: 132,
|
||||
height: 132,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.navActiveGold.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
verticalPadding,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bookMarked,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ayat Hari Ini',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.3,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
reference,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark
|
||||
? Colors.white
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (arabic.isNotEmpty) ...[
|
||||
const SizedBox(height: 28),
|
||||
ArabicText(
|
||||
arabic,
|
||||
baseFontSize: arabicFontSize,
|
||||
height: arabicHeight,
|
||||
textAlign: TextAlign.right,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
],
|
||||
if (translation.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: isDark ? 0.05 : 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'"$translation"',
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: translationFontSize,
|
||||
height: translationHeight,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.navActiveGold.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'Jam Shalat Diary',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Bagikan kebaikan',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AyatShareActionTile extends StatelessWidget {
|
||||
const _AyatShareActionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.badge,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final String? badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.12)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(child: icon),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.navActiveGold.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AyatFramePainter extends CustomPainter {
|
||||
const _AyatFramePainter({required this.isDark});
|
||||
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final outerRect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(22),
|
||||
);
|
||||
|
||||
final outerPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.6
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.navActiveGoldPale,
|
||||
AppColors.navActiveGold,
|
||||
AppColors.navActiveGoldDeep,
|
||||
],
|
||||
).createShader(Offset.zero & size);
|
||||
|
||||
final outerGlow = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 5
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.08 : 0.05);
|
||||
|
||||
final innerBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
|
||||
final innerFrame = RRect.fromRectAndRadius(
|
||||
innerBounds,
|
||||
const Radius.circular(18),
|
||||
);
|
||||
final innerPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8
|
||||
..color = (isDark ? Colors.white : AppColors.primary)
|
||||
.withValues(alpha: isDark ? 0.08 : 0.10);
|
||||
|
||||
canvas.drawRRect(outerRect, outerGlow);
|
||||
canvas.drawRRect(outerRect, outerPaint);
|
||||
canvas.drawRRect(innerFrame, innerPaint);
|
||||
|
||||
_drawMidMotif(canvas, size, top: true);
|
||||
_drawMidMotif(canvas, size, top: false);
|
||||
}
|
||||
|
||||
void _drawMidMotif(Canvas canvas, Size size, {required bool top}) {
|
||||
final y = top ? 14.0 : size.height - 14.0;
|
||||
final centerX = size.width / 2;
|
||||
|
||||
final linePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.9
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.26 : 0.22);
|
||||
final diamondPaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = AppColors.primary.withValues(alpha: isDark ? 0.34 : 0.22);
|
||||
final diamondStroke = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.9
|
||||
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.58 : 0.48);
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(centerX - 26, y),
|
||||
Offset(centerX - 10, y),
|
||||
linePaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(centerX + 10, y),
|
||||
Offset(centerX + 26, y),
|
||||
linePaint,
|
||||
);
|
||||
|
||||
final diamondPath = Path()
|
||||
..moveTo(centerX, y - 5)
|
||||
..lineTo(centerX + 5, y)
|
||||
..lineTo(centerX, y + 5)
|
||||
..lineTo(centerX - 5, y)
|
||||
..close();
|
||||
canvas.drawPath(diamondPath, diamondPaint);
|
||||
canvas.drawPath(diamondPath, diamondStroke);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _AyatFramePainter oldDelegate) {
|
||||
return oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
214
lib/core/widgets/ayat_today_card.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../data/services/muslim_api_service.dart';
|
||||
import 'arabic_text.dart';
|
||||
import 'ayat_share_sheet.dart';
|
||||
|
||||
class AyatTodayCard extends StatefulWidget {
|
||||
const AyatTodayCard({
|
||||
super.key,
|
||||
required this.headerText,
|
||||
required this.headerStyle,
|
||||
});
|
||||
|
||||
final String headerText;
|
||||
final TextStyle headerStyle;
|
||||
|
||||
@override
|
||||
State<AyatTodayCard> createState() => _AyatTodayCardState();
|
||||
}
|
||||
|
||||
class _AyatTodayCardState extends State<AyatTodayCard> {
|
||||
Map<String, dynamic>? _dailyAyat;
|
||||
Map<String, dynamic>? _activeAyat;
|
||||
bool _isLoading = true;
|
||||
bool _isRandomizing = false;
|
||||
bool _showingRandomAyat = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDailyAyat();
|
||||
}
|
||||
|
||||
Future<void> _loadDailyAyat() async {
|
||||
final ayat = await MuslimApiService.instance.getDailyAyat();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dailyAyat = ayat;
|
||||
_activeAyat = ayat;
|
||||
_showingRandomAyat = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showRandomAyat() async {
|
||||
if (_isRandomizing || _activeAyat == null) return;
|
||||
|
||||
setState(() => _isRandomizing = true);
|
||||
final randomAyat = await MuslimApiService.instance.getRandomAyat(
|
||||
excludeSurahNumber: _asInt(_activeAyat?['nomorSurah']),
|
||||
excludeAyahNumber: _asInt(_activeAyat?['nomorAyat']),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRandomizing = false;
|
||||
if (randomAyat != null) {
|
||||
_activeAyat = randomAyat;
|
||||
_showingRandomAyat = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _restoreDailyAyat() {
|
||||
if (_dailyAyat == null) return;
|
||||
setState(() {
|
||||
_activeAyat = _dailyAyat;
|
||||
_showingRandomAyat = false;
|
||||
});
|
||||
}
|
||||
|
||||
int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final backgroundColor = isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0);
|
||||
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final data = _activeAyat;
|
||||
if (data == null) return const SizedBox.shrink();
|
||||
|
||||
final secondaryColor =
|
||||
isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(widget.headerText, style: widget.headerStyle)),
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
glyph: AppIcons.share,
|
||||
size: 18,
|
||||
color: secondaryColor,
|
||||
),
|
||||
tooltip: 'Bagikan ayat',
|
||||
onPressed: () => showAyatShareSheet(context, data),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
data['teksArab'] ?? '',
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'"${data['teksIndonesia'] ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.5,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'QS. ${data['surahName']}: ${data['nomorAyat']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isRandomizing ? null : _showRandomAyat,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
backgroundColor: AppColors.primary.withValues(
|
||||
alpha: isDark ? 0.16 : 0.12,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
icon: _isRandomizing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
)
|
||||
: const AppIcon(
|
||||
glyph: AppIcons.shuffle,
|
||||
size: 16,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
label: Text(_showingRandomAyat ? 'Acak Lagi' : 'Ayat Lain'),
|
||||
),
|
||||
if (_showingRandomAyat)
|
||||
TextButton(
|
||||
onPressed: _restoreDailyAyat,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: secondaryColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
child: const Text('Kembali ke Hari Ini'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// 5-tab bottom navigation bar per PRD §5.1.
|
||||
/// Uses Material Symbols outlined (inactive) and filled (active).
|
||||
class AppBottomNavBar extends StatelessWidget {
|
||||
/// 5-tab bottom navigation bar with luxury active treatment.
|
||||
class AppBottomNavBar extends ConsumerStatefulWidget {
|
||||
const AppBottomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
@@ -17,86 +22,207 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
ConsumerState<AppBottomNavBar> createState() => _AppBottomNavBarState();
|
||||
}
|
||||
|
||||
class _AppBottomNavBarState extends ConsumerState<AppBottomNavBar>
|
||||
with TickerProviderStateMixin {
|
||||
static const double _toggleRevealWidth = 88;
|
||||
static const double _dragThreshold = 38;
|
||||
static const double _inactiveIconSize = 27;
|
||||
static const double _activeIconSize = 22;
|
||||
static const double _navIconStrokeWidth = 1.9;
|
||||
late final AnimationController _shineController;
|
||||
late final AnimationController _revealController;
|
||||
late final Animation<double> _revealAnimation;
|
||||
bool _isThemeToggleOpen = false;
|
||||
double _dragDeltaX = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shineController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 5200),
|
||||
)..repeat();
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 340),
|
||||
);
|
||||
_revealAnimation = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _syncAnimation({required bool reducedMotion}) {
|
||||
if (reducedMotion) {
|
||||
if (_shineController.isAnimating) {
|
||||
_shineController.stop(canceled: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_shineController.isAnimating) {
|
||||
_shineController.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
void _openThemeToggle({bool withHaptics = true}) {
|
||||
if (_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = true;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
_revealController.animateTo(1);
|
||||
}
|
||||
|
||||
void _closeThemeToggle({bool withHaptics = true}) {
|
||||
if (!_isThemeToggleOpen) return;
|
||||
_isThemeToggleOpen = false;
|
||||
if (withHaptics) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
_revealController.animateBack(0);
|
||||
}
|
||||
|
||||
void _handleNavTap(int index) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
widget.onTap(index);
|
||||
}
|
||||
|
||||
void _handleHorizontalDragStart(DragStartDetails _) {
|
||||
_dragDeltaX = 0;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
|
||||
_dragDeltaX += details.delta.dx;
|
||||
}
|
||||
|
||||
void _handleHorizontalDragEnd(DragEndDetails details) {
|
||||
final velocityX = details.primaryVelocity ?? 0;
|
||||
final shouldOpen = velocityX < -220 || _dragDeltaX <= -_dragThreshold;
|
||||
final shouldClose = velocityX > 220 || _dragDeltaX >= _dragThreshold;
|
||||
|
||||
if (shouldOpen && !_isThemeToggleOpen) {
|
||||
_openThemeToggle();
|
||||
} else if (shouldClose && _isThemeToggleOpen) {
|
||||
_closeThemeToggle();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleThemeMode(BuildContext context) async {
|
||||
final isDarkNow = Theme.of(context).brightness == Brightness.dark;
|
||||
final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark;
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default') ?? AppSettings();
|
||||
|
||||
settings.themeModeIndex = nextMode == ThemeMode.dark ? 2 : 1;
|
||||
|
||||
if (settings.isInBox) {
|
||||
await settings.save();
|
||||
} else {
|
||||
await box.put('default', settings);
|
||||
}
|
||||
|
||||
ref.read(themeProvider.notifier).state = nextMode;
|
||||
HapticFeedback.selectionClick();
|
||||
|
||||
if (mounted) {
|
||||
_closeThemeToggle(withHaptics: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shineController.dispose();
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.maybeOf(context);
|
||||
final reducedMotion = (media?.disableAnimations ?? false) ||
|
||||
(media?.accessibleNavigation ?? false);
|
||||
_syncAnimation(reducedMotion: reducedMotion);
|
||||
|
||||
return ValueListenableBuilder<Box<AppSettings>>(
|
||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||
builder: (context, box, _) {
|
||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final systemBottomInset =
|
||||
MediaQueryData.fromView(View.of(context)).viewPadding.bottom;
|
||||
|
||||
final simpleItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
activeIcon: Icon(LucideIcons.bookOpen),
|
||||
label: 'Tilawah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.headphones),
|
||||
activeIcon: Icon(LucideIcons.headphones),
|
||||
label: 'Murattal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.sparkles),
|
||||
activeIcon: Icon(LucideIcons.sparkles),
|
||||
label: 'Zikir',
|
||||
),
|
||||
];
|
||||
final items = isSimpleMode
|
||||
? const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.quran, "Al-Qur'an"),
|
||||
_NavDef(AppIcons.dzikir, 'Dzikir'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
]
|
||||
: const [
|
||||
_NavDef(AppIcons.home, 'Beranda'),
|
||||
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||
_NavDef(AppIcons.ibadah, 'Ibadah'),
|
||||
_NavDef(AppIcons.laporan, 'Laporan'),
|
||||
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||
];
|
||||
|
||||
final fullItems = const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.home),
|
||||
activeIcon: Icon(LucideIcons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
activeIcon: Icon(LucideIcons.calendar),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.listChecks),
|
||||
activeIcon: Icon(LucideIcons.listChecks),
|
||||
label: 'Ibadah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.barChart3),
|
||||
activeIcon: Icon(LucideIcons.barChart3),
|
||||
label: 'Laporan',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(LucideIcons.wand2),
|
||||
activeIcon: Icon(LucideIcons.wand2),
|
||||
label: 'Alat',
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: currentIndex,
|
||||
onTap: onTap,
|
||||
items: isSimpleMode ? simpleItems : fullItems,
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
Colors.transparent,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onHorizontalDragStart: _handleHorizontalDragStart,
|
||||
onHorizontalDragUpdate: _handleHorizontalDragUpdate,
|
||||
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return IgnorePointer(
|
||||
ignoring: reveal < 0.55,
|
||||
child: _ThemeUnderlayBoard(
|
||||
isDark: isDark,
|
||||
reveal: reveal,
|
||||
onTap: () => _toggleThemeMode(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) {
|
||||
final reveal = _revealAnimation.value;
|
||||
return Transform.translate(
|
||||
offset: Offset(-_toggleRevealWidth * reveal, 0),
|
||||
child: _MainNavBoard(
|
||||
items: items,
|
||||
currentIndex: widget.currentIndex,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
animation: _shineController,
|
||||
onTap: _handleNavTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,3 +231,477 @@ class AppBottomNavBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeUnderlayBoard extends StatelessWidget {
|
||||
const _ThemeUnderlayBoard({
|
||||
required this.isDark,
|
||||
required this.reveal,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isDark;
|
||||
final double reveal;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = reveal.clamp(0.0, 1.0).toDouble();
|
||||
return Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
width: _AppBottomNavBarState._toggleRevealWidth,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Transform.translate(
|
||||
offset: Offset((1 - t) * 10, 0),
|
||||
child: Transform.scale(
|
||||
scale: 0.94 + (t * 0.06),
|
||||
child: AppIcon(
|
||||
glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon,
|
||||
size: 24,
|
||||
color: isDark
|
||||
? AppColors.navActiveGoldPale
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavDef {
|
||||
const _NavDef(this.icon, this.label);
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _MainNavBoard extends StatelessWidget {
|
||||
const _MainNavBoard({
|
||||
required this.items,
|
||||
required this.currentIndex,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.animation,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<_NavDef> items;
|
||||
final int currentIndex;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final Animation<double> animation;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final background =
|
||||
Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||
(isDark ? AppColors.surfaceDark : AppColors.surfaceLight);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final isSelected = index == currentIndex;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(index),
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: isSelected
|
||||
? _AnimatedLuxuryActiveIcon(
|
||||
animation: animation,
|
||||
icon: item.icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
iconSize: _AppBottomNavBarState._activeIconSize,
|
||||
)
|
||||
: _InactiveNavIcon(
|
||||
glyph: item.icon,
|
||||
isDark: isDark,
|
||||
iconSize: _AppBottomNavBarState._inactiveIconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InactiveNavIcon extends StatelessWidget {
|
||||
const _InactiveNavIcon({
|
||||
required this.glyph,
|
||||
required this.isDark,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph glyph;
|
||||
final bool isDark;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: glyph,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLuxuryActiveIcon extends StatelessWidget {
|
||||
const _AnimatedLuxuryActiveIcon({
|
||||
required this.animation,
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, __) {
|
||||
return _LuxuryActiveIcon(
|
||||
icon: icon,
|
||||
isDark: isDark,
|
||||
reducedMotion: reducedMotion,
|
||||
progress: reducedMotion ? 0.17 : animation.value,
|
||||
iconSize: iconSize,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryActiveIcon extends StatelessWidget {
|
||||
const _LuxuryActiveIcon({
|
||||
required this.icon,
|
||||
required this.isDark,
|
||||
required this.reducedMotion,
|
||||
required this.progress,
|
||||
required this.iconSize,
|
||||
});
|
||||
|
||||
final AppIconGlyph icon;
|
||||
final bool isDark;
|
||||
final bool reducedMotion;
|
||||
final double progress;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseShadow = isDark
|
||||
? <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: Color(0xB3000000),
|
||||
blurRadius: 9,
|
||||
spreadRadius: 0.2,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navGlowDark,
|
||||
blurRadius: 8,
|
||||
spreadRadius: -0.8,
|
||||
offset: Offset(0, 1.5),
|
||||
),
|
||||
]
|
||||
: <BoxShadow>[
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossHighlight,
|
||||
blurRadius: 3.2,
|
||||
offset: Offset(-1.1, -1.1),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: AppColors.navEmbossShadow,
|
||||
blurRadius: 7,
|
||||
offset: Offset(2.3, 3.1),
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A),
|
||||
width: 0.85,
|
||||
),
|
||||
boxShadow: baseShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF1A202A)
|
||||
: AppColors.navActiveSurfaceLight,
|
||||
borderRadius: BorderRadius.circular(12.5),
|
||||
border: Border.all(
|
||||
color:
|
||||
isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: _LuxuryRingPainter(
|
||||
progress: progress,
|
||||
reducedMotion: reducedMotion,
|
||||
isDark: isDark,
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: icon,
|
||||
size: iconSize,
|
||||
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||
color: isDark
|
||||
? AppColors.textPrimaryDark
|
||||
: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LuxuryRingPainter extends CustomPainter {
|
||||
const _LuxuryRingPainter({
|
||||
required this.progress,
|
||||
required this.reducedMotion,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final bool reducedMotion;
|
||||
final bool isDark;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
final outerRect = rect.deflate(1.05);
|
||||
final ringRect = rect.deflate(2.5);
|
||||
final innerRect = rect.deflate(4.75);
|
||||
|
||||
final outerRRect =
|
||||
RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4));
|
||||
final ringRRect =
|
||||
RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8));
|
||||
final innerRRect =
|
||||
RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1));
|
||||
final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2;
|
||||
|
||||
void drawEmboss(
|
||||
RRect target, {
|
||||
required Offset shadowOffset,
|
||||
required Offset highlightOffset,
|
||||
required Color shadowColor,
|
||||
required Color highlightColor,
|
||||
required double shadowBlur,
|
||||
required double highlightBlur,
|
||||
required double strokeWidth,
|
||||
}) {
|
||||
final shadowPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = shadowColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
|
||||
|
||||
final highlightPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = highlightColor
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(shadowOffset.dx, shadowOffset.dy);
|
||||
canvas.drawRRect(target, shadowPaint);
|
||||
canvas.restore();
|
||||
|
||||
canvas.save();
|
||||
canvas.translate(highlightOffset.dx, highlightOffset.dy);
|
||||
canvas.drawRRect(target, highlightPaint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
drawEmboss(
|
||||
outerRRect,
|
||||
shadowOffset: const Offset(0.7, 1.0),
|
||||
highlightOffset: const Offset(-0.6, -0.7),
|
||||
shadowColor: isDark
|
||||
? const Color(0xD6000000)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.22)
|
||||
: Colors.white.withValues(alpha: 0.92),
|
||||
shadowBlur: isDark ? 2.5 : 1.8,
|
||||
highlightBlur: isDark ? 1.6 : 1.1,
|
||||
strokeWidth: 1.05,
|
||||
);
|
||||
|
||||
drawEmboss(
|
||||
innerRRect,
|
||||
shadowOffset: const Offset(-0.45, -0.35),
|
||||
highlightOffset: const Offset(0.45, 0.55),
|
||||
shadowColor: isDark
|
||||
? Colors.black.withValues(alpha: 0.58)
|
||||
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
|
||||
highlightColor: isDark
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: Colors.white.withValues(alpha: 0.78),
|
||||
shadowBlur: isDark ? 1.5 : 1.2,
|
||||
highlightBlur: isDark ? 1.1 : 0.9,
|
||||
strokeWidth: 0.88,
|
||||
);
|
||||
|
||||
final metallicRing = SweepGradient(
|
||||
startAngle: rotation,
|
||||
endAngle: rotation + math.pi * 2,
|
||||
colors: const [
|
||||
AppColors.navActiveGoldDeep,
|
||||
AppColors.navActiveGold,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldPale,
|
||||
AppColors.navActiveGoldBright,
|
||||
AppColors.navActiveGoldDeep,
|
||||
],
|
||||
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
|
||||
);
|
||||
|
||||
final metallicPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.9
|
||||
..shader = metallicRing.createShader(ringRect)
|
||||
..isAntiAlias = true;
|
||||
|
||||
final chromaStrength = isDark ? 0.92 : 0.74;
|
||||
final chromaSweep = SweepGradient(
|
||||
startAngle: (rotation * 1.3) + 0.42,
|
||||
endAngle: (rotation * 1.3) + 0.42 + math.pi * 2,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.94 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
|
||||
Colors.white.withValues(alpha: 0.88 * chromaStrength),
|
||||
AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [
|
||||
0.0,
|
||||
0.09,
|
||||
0.112,
|
||||
0.126,
|
||||
0.14,
|
||||
0.175,
|
||||
0.45,
|
||||
0.468,
|
||||
0.484,
|
||||
0.5,
|
||||
0.528,
|
||||
1.0,
|
||||
],
|
||||
);
|
||||
|
||||
final chromaPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.15
|
||||
..shader = chromaSweep.createShader(ringRect)
|
||||
..blendMode = BlendMode.screen;
|
||||
|
||||
canvas.drawRRect(ringRRect, metallicPaint);
|
||||
canvas.drawRRect(ringRRect, chromaPaint);
|
||||
|
||||
final ambientGold = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = isDark ? 1.0 : 0.85
|
||||
..color = isDark
|
||||
? AppColors.navActiveGold.withValues(alpha: 0.18)
|
||||
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
|
||||
..maskFilter = MaskFilter.blur(
|
||||
BlurStyle.normal,
|
||||
isDark ? 2.6 : 1.2,
|
||||
);
|
||||
canvas.drawRRect(ringRRect, ambientGold);
|
||||
|
||||
final innerEdge = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 0.8
|
||||
..color = isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: const Color(0x330F172A);
|
||||
canvas.drawRRect(innerRRect, innerEdge);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) {
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.reducedMotion != reducedMotion ||
|
||||
oldDelegate.isDark != isDark;
|
||||
}
|
||||
}
|
||||
|
||||
166
lib/core/widgets/notification_bell_button.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
import '../../data/services/notification_service.dart';
|
||||
import '../../data/services/notification_inbox_service.dart';
|
||||
import '../../features/dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class NotificationBellButton extends StatelessWidget {
|
||||
const NotificationBellButton({
|
||||
super.key,
|
||||
this.iconColor,
|
||||
this.iconSize = 22,
|
||||
this.onPressed,
|
||||
this.showBadge = true,
|
||||
});
|
||||
|
||||
final Color? iconColor;
|
||||
final double iconSize;
|
||||
final VoidCallback? onPressed;
|
||||
final bool showBadge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inbox = NotificationInboxService.instance;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inbox.listenable(),
|
||||
builder: (context, _, __) {
|
||||
final unread = showBadge ? inbox.unreadCount() : 0;
|
||||
return IconButton(
|
||||
onPressed: onPressed ??
|
||||
() {
|
||||
context.push('/notifications');
|
||||
},
|
||||
onLongPress: () => _showQuickActions(context),
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
color: iconColor,
|
||||
size: iconSize,
|
||||
),
|
||||
if (unread > 0)
|
||||
Positioned(
|
||||
right: -6,
|
||||
top: -4,
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
width: 1.4,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
unread > 99 ? '99+' : '$unread',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showQuickActions(BuildContext context) async {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
final alarmsOn = settings.adhanEnabled.values.any((v) => v);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor:
|
||||
isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark.withValues(alpha: 0.4)
|
||||
: AppColors.textSecondaryLight.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
alarmsOn
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_active_outlined,
|
||||
),
|
||||
title: Text(alarmsOn
|
||||
? 'Nonaktifkan Alarm Sholat'
|
||||
: 'Aktifkan Alarm Sholat'),
|
||||
onTap: () async {
|
||||
final container =
|
||||
ProviderScope.containerOf(context, listen: false);
|
||||
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
|
||||
await settings.save();
|
||||
if (alarmsOn) {
|
||||
await NotificationService.instance.cancelAllPending();
|
||||
}
|
||||
container.invalidate(prayerTimesProvider);
|
||||
unawaited(container.read(prayerTimesProvider.future));
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync_rounded),
|
||||
title: const Text('Sinkronkan Sekarang'),
|
||||
onTap: () {
|
||||
final container =
|
||||
ProviderScope.containerOf(context, listen: false);
|
||||
container.invalidate(prayerTimesProvider);
|
||||
unawaited(container.read(prayerTimesProvider.future));
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: const Text('Buka Pengaturan'),
|
||||
onTap: () {
|
||||
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||
context.push('/settings');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/icons/app_icons.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
class ToolCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final AppIconGlyph icon;
|
||||
final String title;
|
||||
final Color color;
|
||||
final bool isDark;
|
||||
@@ -28,9 +29,7 @@ class ToolCard extends StatelessWidget {
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? color.withValues(alpha: 0.15)
|
||||
: AppColors.cream,
|
||||
color: isDark ? color.withValues(alpha: 0.15) : AppColors.cream,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -51,7 +50,14 @@ class ToolCard extends StatelessWidget {
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: AppIcon(
|
||||
glyph: icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -21,6 +21,8 @@ class HiveBoxes {
|
||||
static const String dzikirCounters = 'dzikir_counters';
|
||||
static const String bookmarks = 'bookmarks';
|
||||
static const String cachedPrayerTimes = 'cached_prayer_times';
|
||||
static const String notificationInbox = 'notification_inbox';
|
||||
static const String notificationRuntime = 'notification_runtime';
|
||||
}
|
||||
|
||||
/// Initialize Hive and open all boxes.
|
||||
@@ -56,6 +58,8 @@ Future<void> initHive() async {
|
||||
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
||||
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
||||
await Hive.openBox(HiveBoxes.notificationInbox);
|
||||
await Hive.openBox(HiveBoxes.notificationRuntime);
|
||||
|
||||
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
|
||||
final keysToDelete = [];
|
||||
@@ -69,7 +73,7 @@ Future<void> initHive() async {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (keysToDelete.isNotEmpty) {
|
||||
await worshipBox.deleteAll(keysToDelete);
|
||||
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
|
||||
@@ -89,26 +93,53 @@ Future<void> seedDefaults() async {
|
||||
if (checklistBox.isEmpty) {
|
||||
final defaults = [
|
||||
ChecklistItem(
|
||||
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
|
||||
id: 'fajr',
|
||||
title: 'Sholat Fajr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 0),
|
||||
ChecklistItem(
|
||||
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
|
||||
id: 'dhuhr',
|
||||
title: 'Sholat Dhuhr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 1),
|
||||
ChecklistItem(
|
||||
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
|
||||
id: 'asr',
|
||||
title: 'Sholat Asr',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 2),
|
||||
ChecklistItem(
|
||||
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
|
||||
id: 'maghrib',
|
||||
title: 'Sholat Maghrib',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 3),
|
||||
ChecklistItem(
|
||||
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
|
||||
id: 'isha',
|
||||
title: 'Sholat Isha',
|
||||
category: 'sholat_fardhu',
|
||||
sortOrder: 4),
|
||||
ChecklistItem(
|
||||
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
|
||||
subtitle: '1 Juz', sortOrder: 5),
|
||||
id: 'tilawah',
|
||||
title: 'Tilawah Quran',
|
||||
category: 'tilawah',
|
||||
subtitle: '1 Juz',
|
||||
sortOrder: 5),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 6),
|
||||
id: 'dzikir_pagi',
|
||||
title: 'Dzikir Pagi',
|
||||
category: 'dzikir',
|
||||
subtitle: '1 session',
|
||||
sortOrder: 6),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 7),
|
||||
id: 'dzikir_petang',
|
||||
title: 'Dzikir Petang',
|
||||
category: 'dzikir',
|
||||
subtitle: '1 session',
|
||||
sortOrder: 7),
|
||||
ChecklistItem(
|
||||
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
|
||||
id: 'rawatib',
|
||||
title: 'Sholat Sunnah Rawatib',
|
||||
category: 'sunnah',
|
||||
sortOrder: 8),
|
||||
ChecklistItem(
|
||||
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
||||
];
|
||||
|
||||
@@ -65,6 +65,45 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(19)
|
||||
bool simpleMode; // false = Mode Lengkap, true = Mode Simpel
|
||||
|
||||
@HiveField(20)
|
||||
String dzikirDisplayMode; // 'list' | 'focus'
|
||||
|
||||
@HiveField(21)
|
||||
String dzikirCounterButtonPosition; // 'bottomPill' | 'fabCircle'
|
||||
|
||||
@HiveField(22)
|
||||
bool dzikirAutoAdvance;
|
||||
|
||||
@HiveField(23)
|
||||
bool dzikirHapticOnCount;
|
||||
|
||||
@HiveField(24)
|
||||
bool alertsEnabled;
|
||||
|
||||
@HiveField(25)
|
||||
bool inboxEnabled;
|
||||
|
||||
@HiveField(26)
|
||||
bool streakRiskEnabled;
|
||||
|
||||
@HiveField(27)
|
||||
bool dailyChecklistReminderEnabled;
|
||||
|
||||
@HiveField(28)
|
||||
bool weeklySummaryEnabled;
|
||||
|
||||
@HiveField(29)
|
||||
String quietHoursStart; // HH:mm
|
||||
|
||||
@HiveField(30)
|
||||
String quietHoursEnd; // HH:mm
|
||||
|
||||
@HiveField(31)
|
||||
int maxNonPrayerPushPerDay;
|
||||
|
||||
@HiveField(32)
|
||||
bool mirrorAdzanToInbox;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
@@ -86,6 +125,19 @@ class AppSettings extends HiveObject {
|
||||
this.showLatin = true,
|
||||
this.showTerjemahan = true,
|
||||
this.simpleMode = false,
|
||||
this.dzikirDisplayMode = 'list',
|
||||
this.dzikirCounterButtonPosition = 'bottomPill',
|
||||
this.dzikirAutoAdvance = true,
|
||||
this.dzikirHapticOnCount = true,
|
||||
this.alertsEnabled = true,
|
||||
this.inboxEnabled = true,
|
||||
this.streakRiskEnabled = true,
|
||||
this.dailyChecklistReminderEnabled = false,
|
||||
this.weeklySummaryEnabled = true,
|
||||
this.quietHoursStart = '22:00',
|
||||
this.quietHoursEnd = '05:00',
|
||||
this.maxNonPrayerPushPerDay = 2,
|
||||
this.mirrorAdzanToInbox = false,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
|
||||
@@ -20,30 +20,65 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
|
||||
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
|
||||
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
|
||||
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
|
||||
arabicFontSize:
|
||||
fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
|
||||
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
|
||||
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
|
||||
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
|
||||
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
|
||||
adhanEnabled: fields.containsKey(5)
|
||||
? (fields[5] as Map?)?.cast<String, bool>()
|
||||
: null,
|
||||
iqamahOffset: fields.containsKey(6)
|
||||
? (fields[6] as Map?)?.cast<String, int>()
|
||||
: null,
|
||||
checklistReminderTime:
|
||||
fields.containsKey(7) ? fields[7] as String? : null,
|
||||
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
|
||||
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
|
||||
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
|
||||
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
|
||||
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
|
||||
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
||||
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
||||
tilawahTargetUnit:
|
||||
fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
||||
tilawahAutoSync:
|
||||
fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
||||
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
|
||||
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
|
||||
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
|
||||
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
showTerjemahan:
|
||||
fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
simpleMode: fields.containsKey(19) ? fields[19] as bool? ?? false : false,
|
||||
dzikirDisplayMode:
|
||||
fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||
dzikirCounterButtonPosition: fields.containsKey(21)
|
||||
? fields[21] as String? ?? 'bottomPill'
|
||||
: 'bottomPill',
|
||||
dzikirAutoAdvance:
|
||||
fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||
dzikirHapticOnCount:
|
||||
fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||
alertsEnabled:
|
||||
fields.containsKey(24) ? fields[24] as bool? ?? true : true,
|
||||
inboxEnabled: fields.containsKey(25) ? fields[25] as bool? ?? true : true,
|
||||
streakRiskEnabled:
|
||||
fields.containsKey(26) ? fields[26] as bool? ?? true : true,
|
||||
dailyChecklistReminderEnabled:
|
||||
fields.containsKey(27) ? fields[27] as bool? ?? false : false,
|
||||
weeklySummaryEnabled:
|
||||
fields.containsKey(28) ? fields[28] as bool? ?? true : true,
|
||||
quietHoursStart:
|
||||
fields.containsKey(29) ? fields[29] as String? ?? '22:00' : '22:00',
|
||||
quietHoursEnd:
|
||||
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
|
||||
maxNonPrayerPushPerDay:
|
||||
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
|
||||
mirrorAdzanToInbox:
|
||||
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(20)
|
||||
..writeByte(33)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -83,7 +118,33 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(18)
|
||||
..write(obj.showTerjemahan)
|
||||
..writeByte(19)
|
||||
..write(obj.simpleMode);
|
||||
..write(obj.simpleMode)
|
||||
..writeByte(20)
|
||||
..write(obj.dzikirDisplayMode)
|
||||
..writeByte(21)
|
||||
..write(obj.dzikirCounterButtonPosition)
|
||||
..writeByte(22)
|
||||
..write(obj.dzikirAutoAdvance)
|
||||
..writeByte(23)
|
||||
..write(obj.dzikirHapticOnCount)
|
||||
..writeByte(24)
|
||||
..write(obj.alertsEnabled)
|
||||
..writeByte(25)
|
||||
..write(obj.inboxEnabled)
|
||||
..writeByte(26)
|
||||
..write(obj.streakRiskEnabled)
|
||||
..writeByte(27)
|
||||
..write(obj.dailyChecklistReminderEnabled)
|
||||
..writeByte(28)
|
||||
..write(obj.weeklySummaryEnabled)
|
||||
..writeByte(29)
|
||||
..write(obj.quietHoursStart)
|
||||
..writeByte(30)
|
||||
..write(obj.quietHoursEnd)
|
||||
..writeByte(31)
|
||||
..write(obj.maxNonPrayerPushPerDay)
|
||||
..writeByte(32)
|
||||
..write(obj.mirrorAdzanToInbox);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
657
lib/data/services/muslim_api_service.dart
Normal file
@@ -0,0 +1,657 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class MuslimApiException implements Exception {
|
||||
final String message;
|
||||
const MuslimApiException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'MuslimApiException: $message';
|
||||
}
|
||||
|
||||
/// Service for muslim.backoffice.biz.id API.
|
||||
///
|
||||
/// Exposes Quran, dzikir, doa, hadits, and enrichment data while preserving
|
||||
/// the data contract currently expected by Quran and dashboard UI widgets.
|
||||
class MuslimApiService {
|
||||
static const String _baseUrl = 'https://muslim.backoffice.biz.id';
|
||||
static final MuslimApiService instance = MuslimApiService._();
|
||||
|
||||
MuslimApiService._();
|
||||
|
||||
static const Map<String, String> qariNames = {
|
||||
'01': 'Abdullah Al-Juhany',
|
||||
'02': 'Abdul Muhsin Al-Qasim',
|
||||
'03': 'Abdurrahman As-Sudais',
|
||||
'04': 'Ibrahim Al-Dossari',
|
||||
'05': 'Misyari Rasyid Al-Afasi',
|
||||
'06': 'Yasser Al-Dosari',
|
||||
};
|
||||
|
||||
List<Map<String, dynamic>>? _surahListCache;
|
||||
final Map<int, Map<String, dynamic>> _surahCache = {};
|
||||
|
||||
List<Map<String, dynamic>>? _allAyahCache;
|
||||
List<Map<String, dynamic>>? _tafsirCache;
|
||||
List<Map<String, dynamic>>? _asbabCache;
|
||||
List<Map<String, dynamic>>? _juzCache;
|
||||
List<Map<String, dynamic>>? _themeCache;
|
||||
List<Map<String, dynamic>>? _asmaCache;
|
||||
List<Map<String, dynamic>>? _doaCache;
|
||||
List<Map<String, dynamic>>? _haditsCache;
|
||||
|
||||
final Map<String, List<Map<String, dynamic>>> _dzikirByTypeCache = {};
|
||||
final Map<String, List<Map<String, dynamic>>> _wordByWordCache = {};
|
||||
final Map<int, List<Map<String, dynamic>>> _pageAyahCache = {};
|
||||
|
||||
Future<dynamic> _getData(String path) async {
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$_baseUrl$path'));
|
||||
if (response.statusCode != 200) {
|
||||
return null;
|
||||
}
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded['data'];
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _getDataOrThrow(String path) async {
|
||||
final response = await http.get(Uri.parse('$_baseUrl$path'));
|
||||
if (response.statusCode != 200) {
|
||||
throw MuslimApiException(
|
||||
'Request failed ($path): HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const MuslimApiException('Invalid API payload shape');
|
||||
}
|
||||
|
||||
final status = _asInt(decoded['status']);
|
||||
if (status != 200) {
|
||||
throw MuslimApiException('API returned non-200 status: $status');
|
||||
}
|
||||
|
||||
if (!decoded.containsKey('data')) {
|
||||
throw const MuslimApiException('API payload missing data key');
|
||||
}
|
||||
|
||||
return decoded['data'];
|
||||
}
|
||||
|
||||
int _asInt(dynamic value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String _asString(dynamic value, {String fallback = ''}) {
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
int _asCount(dynamic value, {int fallback = 1}) {
|
||||
if (value == null) return fallback;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
final text = value.toString();
|
||||
final match = RegExp(r'\d+').firstMatch(text);
|
||||
if (match == null) return fallback;
|
||||
return int.tryParse(match.group(0)!) ?? fallback;
|
||||
}
|
||||
|
||||
String _stableDzikirId(String type, Map<String, dynamic> item) {
|
||||
final apiId = _asString(item['id']);
|
||||
if (apiId.isNotEmpty) {
|
||||
return '${type}_$apiId';
|
||||
}
|
||||
|
||||
final seed = [
|
||||
type,
|
||||
_asString(item['type']),
|
||||
_asString(item['arab']),
|
||||
_asString(item['indo']),
|
||||
_asString(item['ulang']),
|
||||
].join('|');
|
||||
|
||||
var hash = 0;
|
||||
for (final unit in seed.codeUnits) {
|
||||
hash = ((hash * 31) + unit) & 0x7fffffff;
|
||||
}
|
||||
return '${type}_$hash';
|
||||
}
|
||||
|
||||
String _dzikirApiType(String type) {
|
||||
switch (type) {
|
||||
case 'petang':
|
||||
return 'sore';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
|
||||
final audioUrl = _extractAudioUrl(audioValue);
|
||||
if (audioUrl.isEmpty) return {};
|
||||
return {
|
||||
'01': audioUrl,
|
||||
'02': audioUrl,
|
||||
'03': audioUrl,
|
||||
'04': audioUrl,
|
||||
'05': audioUrl,
|
||||
'06': audioUrl,
|
||||
};
|
||||
}
|
||||
|
||||
String _extractAudioUrl(dynamic value) {
|
||||
if (value == null) return '';
|
||||
if (value is String) return value.trim();
|
||||
if (value is Map) {
|
||||
final direct = _asString(value['url']).trim();
|
||||
if (direct.isNotEmpty) return direct;
|
||||
final src = _asString(value['src']).trim();
|
||||
if (src.isNotEmpty) return src;
|
||||
final audio = _asString(value['audio']).trim();
|
||||
if (audio.isNotEmpty) return audio;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _normalizeQariKey(dynamic rawKey) {
|
||||
if (rawKey == null) return '';
|
||||
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
|
||||
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
|
||||
|
||||
final text = rawKey.toString().trim();
|
||||
if (text.isEmpty) return '';
|
||||
|
||||
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isNotEmpty) {
|
||||
final parsed = int.tryParse(digits);
|
||||
if (parsed != null) return parsed.toString().padLeft(2, '0');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
|
||||
if (audioValue is Map) {
|
||||
final normalized = <String, String>{};
|
||||
audioValue.forEach((rawKey, rawValue) {
|
||||
final key = _normalizeQariKey(rawKey);
|
||||
final url = _extractAudioUrl(rawValue);
|
||||
if (key.isNotEmpty && url.isNotEmpty) {
|
||||
normalized[key] = url;
|
||||
}
|
||||
});
|
||||
|
||||
if (normalized.isNotEmpty) {
|
||||
final fallbackUrl = normalized.values.first;
|
||||
for (final qariId in qariNames.keys) {
|
||||
normalized.putIfAbsent(qariId, () => fallbackUrl);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return _normalizeAudioMap(audioValue);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
|
||||
final number = _asInt(item['number']);
|
||||
return {
|
||||
'nomor': number,
|
||||
'nama': _asString(item['name_short']),
|
||||
'namaLatin': _asString(item['name_id']),
|
||||
'jumlahAyat': _asInt(item['number_of_verses']),
|
||||
'tempatTurun': _asString(item['revelation_id']),
|
||||
'arti': _asString(item['translation_id']),
|
||||
'deskripsi': _asString(item['tafsir']),
|
||||
'audioFull': _normalizeAudioMap(item['audio_url']),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
|
||||
return {
|
||||
'nomorAyat': _asInt(item['ayah']),
|
||||
'teksArab': _asString(item['arab']),
|
||||
'teksLatin': _asString(item['latin']),
|
||||
'teksIndonesia': _asString(item['text']),
|
||||
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
|
||||
'juz': _asInt(item['juz']),
|
||||
'page': _asInt(item['page']),
|
||||
'hizb': _asInt(item['hizb']),
|
||||
'theme': _asString(item['theme']),
|
||||
'asbab': _asString(item['asbab']),
|
||||
'notes': _asString(item['notes']),
|
||||
'surah': _asInt(item['surah']),
|
||||
'ayahId': _asInt(item['id']),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllSurahs() async {
|
||||
if (_surahListCache != null) return _surahListCache!;
|
||||
final raw = await _getData('/v1/quran/surah');
|
||||
if (raw is! List) return [];
|
||||
_surahListCache =
|
||||
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
|
||||
return _surahListCache!;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getSurah(int number) async {
|
||||
if (_surahCache.containsKey(number)) {
|
||||
return _surahCache[number];
|
||||
}
|
||||
|
||||
final surahs = await getAllSurahs();
|
||||
Map<String, dynamic>? summary;
|
||||
for (final surah in surahs) {
|
||||
if (surah['nomor'] == number) {
|
||||
summary = surah;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final rawAyah = await _getData('/v1/quran/ayah/surah?id=$number');
|
||||
if (summary == null || rawAyah is! List) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mappedAyah =
|
||||
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
|
||||
|
||||
final mapped = {
|
||||
...summary,
|
||||
'ayat': mappedAyah,
|
||||
};
|
||||
_surahCache[number] = mapped;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getDailyAyat() async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final dayOfYear = now.difference(DateTime(now.year, 1, 1)).inDays;
|
||||
final surahId = (dayOfYear % 114) + 1;
|
||||
final surah = await getSurah(surahId);
|
||||
if (surah == null) return null;
|
||||
|
||||
final ayat = List<Map<String, dynamic>>.from(surah['ayat'] ?? []);
|
||||
if (ayat.isEmpty) return null;
|
||||
|
||||
final ayatIndex = dayOfYear % ayat.length;
|
||||
final picked = ayat[ayatIndex];
|
||||
return {
|
||||
'surahName': surah['namaLatin'] ?? '',
|
||||
'nomorSurah': surahId,
|
||||
'nomorAyat': picked['nomorAyat'] ?? 1,
|
||||
'teksArab': picked['teksArab'] ?? '',
|
||||
'teksIndonesia': picked['teksIndonesia'] ?? '',
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getRandomAyat({
|
||||
int? excludeSurahNumber,
|
||||
int? excludeAyahNumber,
|
||||
}) async {
|
||||
try {
|
||||
final allAyah = await getAllAyah();
|
||||
if (allAyah.isEmpty) return null;
|
||||
|
||||
final surahs = await getAllSurahs();
|
||||
if (surahs.isEmpty) return null;
|
||||
|
||||
final surahNames = <int, String>{
|
||||
for (final surah in surahs)
|
||||
_asInt(surah['nomor']): _asString(surah['namaLatin']),
|
||||
};
|
||||
|
||||
final filtered = allAyah.where((ayah) {
|
||||
final surahNumber = _asInt(ayah['surah']);
|
||||
final ayahNumber = _asInt(ayah['ayah']);
|
||||
final isExcluded = excludeSurahNumber != null &&
|
||||
excludeAyahNumber != null &&
|
||||
surahNumber == excludeSurahNumber &&
|
||||
ayahNumber == excludeAyahNumber;
|
||||
if (isExcluded) return false;
|
||||
|
||||
return _asString(ayah['arab']).trim().isNotEmpty &&
|
||||
_asString(ayah['text']).trim().isNotEmpty;
|
||||
}).toList();
|
||||
|
||||
final candidates = filtered.isNotEmpty ? filtered : allAyah;
|
||||
final picked = candidates[Random().nextInt(candidates.length)];
|
||||
final surahNumber = _asInt(picked['surah']);
|
||||
|
||||
return {
|
||||
'surahName': surahNames[surahNumber] ?? '',
|
||||
'nomorSurah': surahNumber,
|
||||
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
|
||||
'teksArab': _asString(picked['arab']),
|
||||
'teksIndonesia': _asString(picked['text']),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWordByWord(
|
||||
int surahId, int ayahId) async {
|
||||
final key = '$surahId:$ayahId';
|
||||
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
|
||||
|
||||
final raw =
|
||||
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
|
||||
if (raw is! List) return [];
|
||||
|
||||
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'word': _asString(item['word']),
|
||||
'arab': _asString(item['arab']),
|
||||
'indo': _asString(item['indo']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
_wordByWordCache[key] = mapped;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllAyah() async {
|
||||
if (_allAyahCache != null) return _allAyahCache!;
|
||||
final raw = await _getData('/v1/quran/ayah');
|
||||
if (raw is! List) return [];
|
||||
|
||||
_allAyahCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'id': _asInt(item['id']),
|
||||
'surah': _asInt(item['surah']),
|
||||
'ayah': _asInt(item['ayah']),
|
||||
'arab': _asString(item['arab']),
|
||||
'latin': _asString(item['latin']),
|
||||
'text': _asString(item['text']),
|
||||
'juz': _asInt(item['juz']),
|
||||
'page': _asInt(item['page']),
|
||||
'hizb': _asInt(item['hizb']),
|
||||
'theme': _asString(item['theme']),
|
||||
'asbab': _asString(item['asbab']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return _allAyahCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getTafsirBySurah(int surahId) async {
|
||||
if (_tafsirCache == null) {
|
||||
final raw = await _getData('/v1/quran/tafsir');
|
||||
if (raw is! List) return [];
|
||||
_tafsirCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'id': _asInt(item['id']),
|
||||
'ayah': _asInt(item['ayah']),
|
||||
'wajiz': _asString(item['wajiz']),
|
||||
'tahlili': _asString(item['tahlili']),
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final allAyah = await getAllAyah();
|
||||
if (allAyah.isEmpty || _tafsirCache == null) return [];
|
||||
|
||||
final ayahById = <int, Map<String, dynamic>>{};
|
||||
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
|
||||
for (final ayah in allAyah) {
|
||||
final id = _asInt(ayah['id']);
|
||||
final surah = _asInt(ayah['surah']);
|
||||
final ayahNumber = _asInt(ayah['ayah']);
|
||||
ayahById[id] = ayah;
|
||||
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
|
||||
}
|
||||
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final tafsir in _tafsirCache!) {
|
||||
final tafsirId = _asInt(tafsir['id']);
|
||||
final tafsirAyah = _asInt(tafsir['ayah']);
|
||||
Map<String, dynamic>? ayahMeta = ayahById[tafsirId];
|
||||
ayahMeta ??= ayahBySurahAyah['$surahId:$tafsirAyah'];
|
||||
if (ayahMeta == null) continue;
|
||||
if (ayahMeta['surah'] != surahId) continue;
|
||||
result.add({
|
||||
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: tafsirAyah),
|
||||
'wajiz': tafsir['wajiz'],
|
||||
'tahlili': tafsir['tahlili'],
|
||||
});
|
||||
}
|
||||
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAsbabBySurah(int surahId) async {
|
||||
if (_asbabCache == null) {
|
||||
final raw = await _getData('/v1/quran/asbab');
|
||||
if (raw is! List) return [];
|
||||
_asbabCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'id': _asInt(item['id']),
|
||||
'ayah': _asInt(item['ayah']),
|
||||
'text': _asString(item['text']),
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final allAyah = await getAllAyah();
|
||||
if (allAyah.isEmpty || _asbabCache == null) return [];
|
||||
|
||||
final ayahById = <int, Map<String, dynamic>>{};
|
||||
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
|
||||
for (final ayah in allAyah) {
|
||||
final id = _asInt(ayah['id']);
|
||||
final surah = _asInt(ayah['surah']);
|
||||
final ayahNumber = _asInt(ayah['ayah']);
|
||||
ayahById[id] = ayah;
|
||||
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
|
||||
}
|
||||
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final asbab in _asbabCache!) {
|
||||
final asbabId = _asInt(asbab['id']);
|
||||
final asbabAyah = _asInt(asbab['ayah']);
|
||||
Map<String, dynamic>? ayahMeta = ayahById[asbabId];
|
||||
ayahMeta ??= ayahBySurahAyah['$surahId:$asbabAyah'];
|
||||
if (ayahMeta == null) continue;
|
||||
if (ayahMeta['surah'] != surahId) continue;
|
||||
result.add({
|
||||
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: asbabAyah),
|
||||
'text': asbab['text'],
|
||||
});
|
||||
}
|
||||
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getJuzList() async {
|
||||
if (_juzCache != null) return _juzCache!;
|
||||
final raw = await _getData('/v1/quran/juz');
|
||||
if (raw is! List) return [];
|
||||
|
||||
_juzCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'number': _asInt(item['number']),
|
||||
'name': _asString(item['name']),
|
||||
'surah_id_start': _asInt(item['surah_id_start']),
|
||||
'verse_start': _asInt(item['verse_start']),
|
||||
'surah_id_end': _asInt(item['surah_id_end']),
|
||||
'verse_end': _asInt(item['verse_end']),
|
||||
'name_start_id': _asString(item['name_start_id']),
|
||||
'name_end_id': _asString(item['name_end_id']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return _juzCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAyahByPage(int page) async {
|
||||
if (_pageAyahCache.containsKey(page)) return _pageAyahCache[page]!;
|
||||
final raw = await _getData('/v1/quran/ayah/page?id=$page');
|
||||
if (raw is! List) return [];
|
||||
|
||||
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'surah': _asInt(item['surah']),
|
||||
'ayah': _asInt(item['ayah']),
|
||||
'arab': _asString(item['arab']),
|
||||
'text': _asString(item['text']),
|
||||
'theme': _asString(item['theme']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
_pageAyahCache[page] = mapped;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getThemes() async {
|
||||
if (_themeCache != null) return _themeCache!;
|
||||
final raw = await _getData('/v1/quran/theme');
|
||||
if (raw is! List) return [];
|
||||
|
||||
_themeCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'id': _asInt(item['id']),
|
||||
'name': _asString(item['name']),
|
||||
};
|
||||
}).toList();
|
||||
return _themeCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchAyah(String query) async {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return [];
|
||||
|
||||
final allAyah = await getAllAyah();
|
||||
final results = allAyah
|
||||
.where((item) {
|
||||
final text = _asString(item['text']).toLowerCase();
|
||||
final latin = _asString(item['latin']).toLowerCase();
|
||||
final arab = _asString(item['arab']);
|
||||
return text.contains(q) ||
|
||||
latin.contains(q) ||
|
||||
arab.contains(query.trim());
|
||||
})
|
||||
.take(50)
|
||||
.toList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAsmaulHusna() async {
|
||||
if (_asmaCache != null) return _asmaCache!;
|
||||
final raw = await _getData('/v1/quran/asma');
|
||||
if (raw is! List) return [];
|
||||
|
||||
_asmaCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'id': _asInt(item['id']),
|
||||
'arab': _asString(item['arab']),
|
||||
'latin': _asString(item['latin']),
|
||||
'indo': _asString(item['indo']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return _asmaCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
|
||||
if (_doaCache != null) return _doaCache!;
|
||||
final raw =
|
||||
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
|
||||
if (raw is! List) {
|
||||
if (strict) {
|
||||
throw const MuslimApiException('Invalid doa payload');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_doaCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'judul': _asString(item['judul']),
|
||||
'arab': _asString(item['arab']),
|
||||
'indo': _asString(item['indo']),
|
||||
'source': _asString(item['source']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return _doaCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getHaditsList(
|
||||
{bool strict = false}) async {
|
||||
if (_haditsCache != null) return _haditsCache!;
|
||||
final raw = strict
|
||||
? await _getDataOrThrow('/v1/hadits')
|
||||
: await _getData('/v1/hadits');
|
||||
if (raw is! List) {
|
||||
if (strict) {
|
||||
throw const MuslimApiException('Invalid hadits payload');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_haditsCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||
return {
|
||||
'no': _asInt(item['no']),
|
||||
'judul': _asString(item['judul']),
|
||||
'arab': _asString(item['arab']),
|
||||
'indo': _asString(item['indo']),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return _haditsCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getDzikirByType(
|
||||
String type, {
|
||||
bool strict = false,
|
||||
}) async {
|
||||
if (_dzikirByTypeCache.containsKey(type)) {
|
||||
return _dzikirByTypeCache[type]!;
|
||||
}
|
||||
final apiType = _dzikirApiType(type);
|
||||
final raw = strict
|
||||
? await _getDataOrThrow('/v1/dzikir?type=$apiType')
|
||||
: await _getData('/v1/dzikir?type=$apiType');
|
||||
if (raw is! List) {
|
||||
if (strict) {
|
||||
throw MuslimApiException('Invalid dzikir payload for type: $type');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
final mapped = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
final item = raw[i];
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
mapped.add({
|
||||
'id': _stableDzikirId(type, item),
|
||||
'arab': _asString(item['arab']),
|
||||
'indo': _asString(item['indo']),
|
||||
'type': _asString(item['type']),
|
||||
'ulang': _asCount(item['ulang'], fallback: 1),
|
||||
});
|
||||
}
|
||||
|
||||
_dzikirByTypeCache[type] = mapped;
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
39
lib/data/services/notification_analytics_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
|
||||
/// Lightweight local analytics sink for notification events.
|
||||
class NotificationAnalyticsService {
|
||||
NotificationAnalyticsService._();
|
||||
static final NotificationAnalyticsService instance =
|
||||
NotificationAnalyticsService._();
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||
|
||||
Future<void> track(
|
||||
String event, {
|
||||
Map<String, dynamic> dimensions = const <String, dynamic>{},
|
||||
}) async {
|
||||
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final counterKey = 'analytics.$date.$event';
|
||||
final current = (_box.get(counterKey) as int?) ?? 0;
|
||||
await _box.put(counterKey, current + 1);
|
||||
|
||||
// Keep a small rolling audit buffer for debug support.
|
||||
final raw = (_box.get('analytics.recent') ?? '[]').toString();
|
||||
final decoded = json.decode(raw);
|
||||
final list = decoded is List ? decoded : <dynamic>[];
|
||||
list.add({
|
||||
'event': event,
|
||||
'at': DateTime.now().toIso8601String(),
|
||||
'dimensions': dimensions,
|
||||
});
|
||||
while (list.length > 100) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
await _box.put('analytics.recent', json.encode(list));
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_event_producer_service.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/app_settings.dart';
|
||||
import '../local/models/daily_worship_log.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Creates in-app inbox events from runtime/system conditions.
|
||||
class NotificationEventProducerService {
|
||||
NotificationEventProducerService._();
|
||||
static final NotificationEventProducerService instance =
|
||||
NotificationEventProducerService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
final NotificationRuntimeService _runtime =
|
||||
NotificationRuntimeService.instance;
|
||||
|
||||
Future<void> emitPermissionWarningsIfNeeded({
|
||||
required AppSettings settings,
|
||||
required NotificationPermissionStatus permissionStatus,
|
||||
}) async {
|
||||
if (!settings.adhanEnabled.values.any((v) => v)) return;
|
||||
|
||||
final dateKey = _todayKey();
|
||||
|
||||
if (!permissionStatus.notificationsAllowed) {
|
||||
final title = 'Izin notifikasi dinonaktifkan';
|
||||
final body =
|
||||
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: 'system.permission.notifications.$dateKey',
|
||||
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.system.permission.notifications.$dateKey',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissionStatus.exactAlarmAllowed) {
|
||||
final title = 'Izin alarm presisi belum aktif';
|
||||
final body =
|
||||
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: 'system.permission.exact_alarm.$dateKey',
|
||||
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emitScheduleFallback({
|
||||
required AppSettings settings,
|
||||
required String cityId,
|
||||
required bool locationUnavailable,
|
||||
}) async {
|
||||
final dateKey = _todayKey();
|
||||
final title = locationUnavailable
|
||||
? 'Lokasi belum tersedia'
|
||||
: 'Jadwal online terganggu';
|
||||
final body = locationUnavailable
|
||||
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
|
||||
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
|
||||
final scope = locationUnavailable ? 'loc' : 'net';
|
||||
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
|
||||
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/imsakiyah',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||
meta: <String, dynamic>{
|
||||
'cityId': cityId,
|
||||
'date': dateKey,
|
||||
'scope': scope,
|
||||
},
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> emitNotificationSyncFailed({
|
||||
required AppSettings settings,
|
||||
required String cityId,
|
||||
}) async {
|
||||
final dateKey = _todayKey();
|
||||
final title = 'Sinkronisasi alarm adzan gagal';
|
||||
final body =
|
||||
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
|
||||
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
|
||||
|
||||
if (settings.inboxEnabled) {
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'system',
|
||||
source: 'local',
|
||||
deeplink: '/settings',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||
meta: <String, dynamic>{
|
||||
'cityId': cityId,
|
||||
'date': dateKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
await _pushSystemIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> emitStreakRiskIfNeeded({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
|
||||
final now = DateTime.now();
|
||||
if (now.hour < 18) return;
|
||||
|
||||
final dateKey = _todayKey();
|
||||
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = worshipBox.get(dateKey);
|
||||
if (log == null) return;
|
||||
|
||||
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
|
||||
final dzikirRisk =
|
||||
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
|
||||
|
||||
if (tilawahRisk) {
|
||||
final title = 'Streak Tilawah berisiko terputus';
|
||||
const body =
|
||||
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
|
||||
final dedupe = 'streak.tilawah.$dateKey';
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'streak_risk',
|
||||
source: 'local',
|
||||
deeplink: '/quran',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||
);
|
||||
await _pushHabitIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
if (dzikirRisk) {
|
||||
final title = 'Dzikir petang belum tercatat';
|
||||
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
|
||||
final dedupe = 'streak.dzikir.$dateKey';
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: 'streak_risk',
|
||||
source: 'local',
|
||||
deeplink: '/tools/dzikir',
|
||||
dedupeKey: dedupe,
|
||||
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||
);
|
||||
await _pushHabitIfAllowed(
|
||||
settings: settings,
|
||||
dedupeSeed: 'push.$dedupe',
|
||||
title: title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emitWeeklySummaryIfNeeded({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (now.weekday != DateTime.monday || now.hour < 6) return;
|
||||
|
||||
final monday = now.subtract(Duration(days: now.weekday - 1));
|
||||
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
|
||||
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
|
||||
|
||||
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
var completionDays = 0;
|
||||
var totalPoints = 0;
|
||||
|
||||
for (int i = 1; i <= 7; i++) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||
final log = worshipBox.get(key);
|
||||
if (log == null) continue;
|
||||
if (log.completionPercent >= 70) completionDays++;
|
||||
totalPoints += log.totalPoints;
|
||||
}
|
||||
|
||||
await _inbox.addItem(
|
||||
title: 'Ringkasan Ibadah Mingguan',
|
||||
body:
|
||||
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
|
||||
type: 'summary',
|
||||
source: 'local',
|
||||
deeplink: '/laporan',
|
||||
dedupeKey: 'summary.weekly.$weekKey',
|
||||
expiresAt: now.add(const Duration(days: 7)),
|
||||
);
|
||||
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
|
||||
}
|
||||
|
||||
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
Future<void> _pushSystemIfAllowed({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _pushNonPrayer(
|
||||
settings: settings,
|
||||
dedupeSeed: dedupeSeed,
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'system',
|
||||
silent: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pushHabitIfAllowed({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await _pushNonPrayer(
|
||||
settings: settings,
|
||||
dedupeSeed: dedupeSeed,
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'streak_risk',
|
||||
silent: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pushNonPrayer({
|
||||
required AppSettings settings,
|
||||
required String dedupeSeed,
|
||||
required String title,
|
||||
required String body,
|
||||
required String payloadType,
|
||||
required bool silent,
|
||||
}) async {
|
||||
if (!settings.alertsEnabled) return;
|
||||
final notif = NotificationService.instance;
|
||||
await notif.showNonPrayerAlert(
|
||||
settings: settings,
|
||||
id: notif.nonPrayerNotificationId(dedupeSeed),
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: payloadType,
|
||||
silent: silent,
|
||||
);
|
||||
}
|
||||
}
|
||||
299
lib/data/services/notification_inbox_service.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import 'notification_analytics_service.dart';
|
||||
|
||||
class NotificationInboxItem {
|
||||
const NotificationInboxItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
required this.readAt,
|
||||
required this.isPinned,
|
||||
required this.source,
|
||||
required this.deeplink,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final String type;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? readAt;
|
||||
final bool isPinned;
|
||||
final String source;
|
||||
final String? deeplink;
|
||||
final Map<String, dynamic> meta;
|
||||
|
||||
bool get isRead => readAt != null;
|
||||
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'type': type,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
'readAt': readAt?.toIso8601String(),
|
||||
'isPinned': isPinned,
|
||||
'source': source,
|
||||
'deeplink': deeplink,
|
||||
'meta': meta,
|
||||
};
|
||||
|
||||
static NotificationInboxItem fromMap(Map<dynamic, dynamic> map) {
|
||||
final createdRaw = (map['createdAt'] ?? '').toString();
|
||||
final expiresRaw = (map['expiresAt'] ?? '').toString();
|
||||
final readRaw = (map['readAt'] ?? '').toString();
|
||||
final rawMeta = map['meta'];
|
||||
|
||||
return NotificationInboxItem(
|
||||
id: (map['id'] ?? '').toString(),
|
||||
title: (map['title'] ?? '').toString(),
|
||||
body: (map['body'] ?? '').toString(),
|
||||
type: (map['type'] ?? 'system').toString(),
|
||||
createdAt: DateTime.tryParse(createdRaw) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
expiresAt: expiresRaw.isEmpty ? null : DateTime.tryParse(expiresRaw),
|
||||
readAt: readRaw.isEmpty ? null : DateTime.tryParse(readRaw),
|
||||
isPinned: map['isPinned'] == true,
|
||||
source: (map['source'] ?? 'local').toString(),
|
||||
deeplink: ((map['deeplink'] ?? '').toString().trim().isEmpty)
|
||||
? null
|
||||
: (map['deeplink'] ?? '').toString(),
|
||||
meta: rawMeta is Map
|
||||
? rawMeta.map((k, v) => MapEntry(k.toString(), v))
|
||||
: const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationInboxService {
|
||||
NotificationInboxService._();
|
||||
static final NotificationInboxService instance = NotificationInboxService._();
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationInbox);
|
||||
|
||||
ValueListenable<Box> listenable() => _box.listenable();
|
||||
|
||||
List<NotificationInboxItem> allItems({
|
||||
String filter = 'all',
|
||||
}) {
|
||||
final items = _box.values
|
||||
.whereType<Map>()
|
||||
.map((raw) => NotificationInboxItem.fromMap(raw))
|
||||
.where((item) => !item.isExpired)
|
||||
.where((item) {
|
||||
switch (filter) {
|
||||
case 'unread':
|
||||
return !item.isRead;
|
||||
case 'system':
|
||||
return item.type == 'system';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
if (a.isPinned != b.isPinned) {
|
||||
return a.isPinned ? -1 : 1;
|
||||
}
|
||||
return b.createdAt.compareTo(a.createdAt);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
int unreadCount() => allItems().where((e) => !e.isRead).length;
|
||||
|
||||
Future<void> addItem({
|
||||
required String title,
|
||||
required String body,
|
||||
required String type,
|
||||
String source = 'local',
|
||||
String? deeplink,
|
||||
String? dedupeKey,
|
||||
DateTime? expiresAt,
|
||||
bool isPinned = false,
|
||||
Map<String, dynamic> meta = const <String, dynamic>{},
|
||||
}) async {
|
||||
final key = dedupeKey ?? _defaultKey(type, title, body);
|
||||
if (_box.containsKey(key)) {
|
||||
final existingRaw = _box.get(key);
|
||||
if (existingRaw is Map) {
|
||||
final existing = NotificationInboxItem.fromMap(existingRaw);
|
||||
await _box.put(
|
||||
key,
|
||||
existing
|
||||
.copyWith(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type,
|
||||
source: source,
|
||||
deeplink: deeplink,
|
||||
expiresAt: expiresAt ?? existing.expiresAt,
|
||||
isPinned: isPinned || existing.isPinned,
|
||||
meta: meta.isEmpty ? existing.meta : meta,
|
||||
)
|
||||
.toMap(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final item = NotificationInboxItem(
|
||||
id: key,
|
||||
title: title,
|
||||
body: body,
|
||||
type: type,
|
||||
createdAt: DateTime.now(),
|
||||
expiresAt: expiresAt,
|
||||
readAt: null,
|
||||
isPinned: isPinned,
|
||||
source: source,
|
||||
deeplink: deeplink,
|
||||
meta: meta,
|
||||
);
|
||||
await _box.put(key, item.toMap());
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_created',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': type,
|
||||
'source': source,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markRead(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isRead) return;
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(readAt: DateTime.now()).toMap(),
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_mark_read',
|
||||
dimensions: <String, dynamic>{'event_type': item.type},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markUnread(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (!item.isRead) return;
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(readAt: null).toMap(),
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_mark_unread',
|
||||
dimensions: <String, dynamic>{'event_type': item.type},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
final updates = <dynamic, Map<String, dynamic>>{};
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isRead) continue;
|
||||
updates[key] = item.copyWith(readAt: DateTime.now()).toMap();
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await _box.putAll(updates);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await _box.delete(id);
|
||||
}
|
||||
|
||||
Future<void> removeByType(String type) async {
|
||||
final keys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.type == type) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
if (keys.isNotEmpty) {
|
||||
await _box.deleteAll(keys);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePinned(String id) async {
|
||||
final raw = _box.get(id);
|
||||
if (raw is! Map) return;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
await _box.put(
|
||||
id,
|
||||
item.copyWith(isPinned: !item.isPinned).toMap(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeExpired() async {
|
||||
final expiredKeys = <dynamic>[];
|
||||
for (final key in _box.keys) {
|
||||
final raw = _box.get(key);
|
||||
if (raw is! Map) continue;
|
||||
final item = NotificationInboxItem.fromMap(raw);
|
||||
if (item.isExpired) expiredKeys.add(key);
|
||||
}
|
||||
if (expiredKeys.isNotEmpty) {
|
||||
await _box.deleteAll(expiredKeys);
|
||||
}
|
||||
}
|
||||
|
||||
String _defaultKey(String type, String title, String body) {
|
||||
final seed = '$type|$title|$body';
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 31 * hash + rune;
|
||||
}
|
||||
return 'inbox_${hash.abs()}';
|
||||
}
|
||||
}
|
||||
|
||||
extension on NotificationInboxItem {
|
||||
static const _readAtUnchanged = Object();
|
||||
|
||||
NotificationInboxItem copyWith({
|
||||
String? title,
|
||||
String? body,
|
||||
String? type,
|
||||
DateTime? createdAt,
|
||||
DateTime? expiresAt,
|
||||
Object? readAt = _readAtUnchanged,
|
||||
bool? isPinned,
|
||||
String? source,
|
||||
String? deeplink,
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return NotificationInboxItem(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
body: body ?? this.body,
|
||||
type: type ?? this.type,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
readAt: identical(readAt, _readAtUnchanged)
|
||||
? this.readAt
|
||||
: readAt as DateTime?,
|
||||
isPinned: isPinned ?? this.isPinned,
|
||||
source: source ?? this.source,
|
||||
deeplink: deeplink ?? this.deeplink,
|
||||
meta: meta ?? this.meta,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/data/services/notification_orchestrator_service.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_event_producer_service.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'remote_notification_content_service.dart';
|
||||
|
||||
/// High-level coordinator for non-prayer notification flows.
|
||||
class NotificationOrchestratorService {
|
||||
NotificationOrchestratorService._();
|
||||
static final NotificationOrchestratorService instance =
|
||||
NotificationOrchestratorService._();
|
||||
|
||||
Future<void> runPassivePass({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
await NotificationInboxService.instance.removeExpired();
|
||||
await NotificationEventProducerService.instance.emitStreakRiskIfNeeded(
|
||||
settings: settings,
|
||||
);
|
||||
await NotificationEventProducerService.instance.emitWeeklySummaryIfNeeded(
|
||||
settings: settings,
|
||||
);
|
||||
await RemoteNotificationContentService.instance.sync(settings: settings);
|
||||
}
|
||||
}
|
||||
86
lib/data/services/notification_runtime_service.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/app_settings.dart';
|
||||
|
||||
/// Runtime persistence for notification counters and cursors.
|
||||
class NotificationRuntimeService {
|
||||
NotificationRuntimeService._();
|
||||
static final NotificationRuntimeService instance =
|
||||
NotificationRuntimeService._();
|
||||
|
||||
static const _nonPrayerCountPrefix = 'non_prayer_push_count.';
|
||||
static const _lastRemoteSyncKey = 'remote.last_sync_at';
|
||||
static const _lastWeeklySummaryKey = 'summary.last_week_key';
|
||||
|
||||
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||
|
||||
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
int nonPrayerPushCountToday() {
|
||||
return (_box.get('$_nonPrayerCountPrefix${_todayKey()}') as int?) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> incrementNonPrayerPushCount() async {
|
||||
final key = '$_nonPrayerCountPrefix${_todayKey()}';
|
||||
final next = ((_box.get(key) as int?) ?? 0) + 1;
|
||||
await _box.put(key, next);
|
||||
}
|
||||
|
||||
bool isWithinQuietHours(AppSettings settings, {DateTime? now}) {
|
||||
final current = now ?? DateTime.now();
|
||||
final startParts = _parseHourMinute(settings.quietHoursStart);
|
||||
final endParts = _parseHourMinute(settings.quietHoursEnd);
|
||||
if (startParts == null || endParts == null) return false;
|
||||
|
||||
final currentMinutes = current.hour * 60 + current.minute;
|
||||
final startMinutes = startParts.$1 * 60 + startParts.$2;
|
||||
final endMinutes = endParts.$1 * 60 + endParts.$2;
|
||||
|
||||
if (startMinutes == endMinutes) {
|
||||
// Same value means quiet-hours disabled.
|
||||
return false;
|
||||
}
|
||||
if (startMinutes < endMinutes) {
|
||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||
}
|
||||
// Overnight interval (e.g. 22:00 -> 05:00).
|
||||
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||
}
|
||||
|
||||
bool canSendNonPrayerPush(AppSettings settings, {DateTime? now}) {
|
||||
if (!settings.alertsEnabled) return false;
|
||||
if (isWithinQuietHours(settings, now: now)) return false;
|
||||
return nonPrayerPushCountToday() < settings.maxNonPrayerPushPerDay;
|
||||
}
|
||||
|
||||
DateTime? lastRemoteSyncAt() {
|
||||
final raw = (_box.get(_lastRemoteSyncKey) ?? '').toString();
|
||||
if (raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
Future<void> setLastRemoteSyncAt(DateTime value) async {
|
||||
await _box.put(_lastRemoteSyncKey, value.toIso8601String());
|
||||
}
|
||||
|
||||
String? lastWeeklySummaryWeekKey() {
|
||||
final raw = (_box.get(_lastWeeklySummaryKey) ?? '').toString();
|
||||
return raw.isEmpty ? null : raw;
|
||||
}
|
||||
|
||||
Future<void> setLastWeeklySummaryWeekKey(String key) async {
|
||||
await _box.put(_lastWeeklySummaryKey, key);
|
||||
}
|
||||
|
||||
(int, int)? _parseHourMinute(String value) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
|
||||
if (match == null) return null;
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return (hour, minute);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,43 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz_data;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
/// Notification service for Adhan and Iqamah notifications.
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_analytics_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
|
||||
class NotificationPermissionStatus {
|
||||
const NotificationPermissionStatus({
|
||||
required this.notificationsAllowed,
|
||||
required this.exactAlarmAllowed,
|
||||
});
|
||||
|
||||
final bool notificationsAllowed;
|
||||
final bool exactAlarmAllowed;
|
||||
}
|
||||
|
||||
class NotificationPendingAlert {
|
||||
const NotificationPendingAlert({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.scheduledAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime? scheduledAt;
|
||||
}
|
||||
|
||||
/// Notification service for Adzan and Iqamah reminders.
|
||||
///
|
||||
/// This service owns the local notifications setup, permission requests,
|
||||
/// timezone setup, and scheduling lifecycle for prayer notifications.
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
static final NotificationService instance = NotificationService._();
|
||||
@@ -10,16 +46,100 @@ class NotificationService {
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
String? _lastSyncSignature;
|
||||
static const int _checklistReminderId = 920001;
|
||||
|
||||
/// Initialize notification channels.
|
||||
static const _adhanDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'adhan_channel',
|
||||
'Adzan Notifications',
|
||||
channelDescription: 'Pengingat waktu adzan',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _iqamahDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'iqamah_channel',
|
||||
'Iqamah Reminders',
|
||||
channelDescription: 'Pengingat waktu iqamah',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _habitDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'habit_channel',
|
||||
'Pengingat Ibadah Harian',
|
||||
channelDescription: 'Pengingat checklist, streak, dan kebiasaan ibadah',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
static const _systemDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'system_channel',
|
||||
'Peringatan Sistem',
|
||||
channelDescription: 'Peringatan status izin dan sinkronisasi jadwal',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
playSound: false,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: false,
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: false,
|
||||
),
|
||||
);
|
||||
|
||||
/// Initialize plugin, permissions, and timezone once.
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
tz_data.initializeTimeZones();
|
||||
_configureLocalTimeZone();
|
||||
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const darwinSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
const settings = InitializationSettings(
|
||||
@@ -28,71 +148,509 @@ class NotificationService {
|
||||
macOS: darwinSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(settings);
|
||||
await _plugin.initialize(settings: settings);
|
||||
await _requestPermissions();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Schedule an Adhan notification at a specific time.
|
||||
Future<void> scheduleAdhan({
|
||||
void _configureLocalTimeZone() {
|
||||
final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset);
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation(tzId));
|
||||
} catch (_) {
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
}
|
||||
}
|
||||
|
||||
// We prioritize Indonesian zones for better prayer scheduling defaults.
|
||||
String _resolveTimeZoneIdByOffset(Duration offset) {
|
||||
switch (offset.inMinutes) {
|
||||
case 420:
|
||||
return 'Asia/Jakarta';
|
||||
case 480:
|
||||
return 'Asia/Makassar';
|
||||
case 540:
|
||||
return 'Asia/Jayapura';
|
||||
default:
|
||||
if (offset.inMinutes % 60 == 0) {
|
||||
final etcHours = -(offset.inMinutes ~/ 60);
|
||||
final sign = etcHours >= 0 ? '+' : '';
|
||||
return 'Etc/GMT$sign$etcHours';
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
await androidPlugin?.requestNotificationsPermission();
|
||||
await androidPlugin?.requestExactAlarmsPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncPrayerNotifications({
|
||||
required String cityId,
|
||||
required Map<String, bool> adhanEnabled,
|
||||
required Map<String, int> iqamahOffset,
|
||||
required Map<String, Map<String, String>> schedulesByDate,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
|
||||
if (!hasAnyEnabled) {
|
||||
await cancelAllPending();
|
||||
_lastSyncSignature = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final signature = _buildSyncSignature(
|
||||
cityId, adhanEnabled, iqamahOffset, schedulesByDate);
|
||||
if (_lastSyncSignature == signature) return;
|
||||
|
||||
await cancelAllPending();
|
||||
|
||||
final now = DateTime.now();
|
||||
final dateEntries = schedulesByDate.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
for (final dateEntry in dateEntries) {
|
||||
final date = DateTime.tryParse(dateEntry.key);
|
||||
if (date == null) continue;
|
||||
|
||||
for (final prayerKey in const [
|
||||
'subuh',
|
||||
'dzuhur',
|
||||
'ashar',
|
||||
'maghrib',
|
||||
'isya',
|
||||
]) {
|
||||
final canonicalPrayer = _canonicalPrayerKey(prayerKey);
|
||||
if (canonicalPrayer == null) continue;
|
||||
if (!(adhanEnabled[canonicalPrayer] ?? false)) continue;
|
||||
|
||||
final rawTime = (dateEntry.value[prayerKey] ?? '').trim();
|
||||
final prayerTime = _parseScheduleDateTime(date, rawTime);
|
||||
if (prayerTime == null || !prayerTime.isAfter(now)) continue;
|
||||
|
||||
await _scheduleAdhan(
|
||||
id: _notificationId(
|
||||
cityId: cityId,
|
||||
dateKey: dateEntry.key,
|
||||
prayerKey: canonicalPrayer,
|
||||
isIqamah: false,
|
||||
),
|
||||
prayerName: _localizedPrayerName(canonicalPrayer),
|
||||
time: prayerTime,
|
||||
);
|
||||
|
||||
final offsetMinutes = iqamahOffset[canonicalPrayer] ?? 0;
|
||||
if (offsetMinutes <= 0) continue;
|
||||
|
||||
final iqamahTime = prayerTime.add(Duration(minutes: offsetMinutes));
|
||||
if (!iqamahTime.isAfter(now)) continue;
|
||||
|
||||
await _scheduleIqamah(
|
||||
id: _notificationId(
|
||||
cityId: cityId,
|
||||
dateKey: dateEntry.key,
|
||||
prayerKey: canonicalPrayer,
|
||||
isIqamah: true,
|
||||
),
|
||||
prayerName: _localizedPrayerName(canonicalPrayer),
|
||||
iqamahTime: iqamahTime,
|
||||
offsetMinutes: offsetMinutes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSyncSignature = signature;
|
||||
}
|
||||
|
||||
Future<void> _scheduleAdhan({
|
||||
required int id,
|
||||
required String prayerName,
|
||||
required DateTime time,
|
||||
}) async {
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
'Adhan - $prayerName',
|
||||
'It\'s time for $prayerName prayer',
|
||||
tz.TZDateTime.from(time, tz.local),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'adhan_channel',
|
||||
'Adhan Notifications',
|
||||
channelDescription: 'Prayer time adhan notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
id: id,
|
||||
title: 'Adzan • $prayerName',
|
||||
body: 'Waktu sholat $prayerName telah masuk.',
|
||||
scheduledDate: tz.TZDateTime.from(time, tz.local),
|
||||
notificationDetails: _adhanDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: 'adhan|$prayerName|${time.toIso8601String()}',
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_scheduled',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': 'adhan',
|
||||
'prayer': prayerName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedule an Iqamah reminder notification.
|
||||
Future<void> scheduleIqamah({
|
||||
Future<void> _scheduleIqamah({
|
||||
required int id,
|
||||
required String prayerName,
|
||||
required DateTime adhanTime,
|
||||
required DateTime iqamahTime,
|
||||
required int offsetMinutes,
|
||||
}) async {
|
||||
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
|
||||
await _plugin.zonedSchedule(
|
||||
id + 100, // Offset IDs for iqamah
|
||||
'Iqamah - $prayerName',
|
||||
'Iqamah for $prayerName in $offsetMinutes minutes',
|
||||
tz.TZDateTime.from(iqamahTime, tz.local),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'iqamah_channel',
|
||||
'Iqamah Reminders',
|
||||
channelDescription: 'Iqamah reminder notifications',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
id: id,
|
||||
title: 'Iqamah • $prayerName',
|
||||
body: 'Iqamah $prayerName dalam $offsetMinutes menit.',
|
||||
scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local),
|
||||
notificationDetails: _iqamahDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: 'iqamah|$prayerName|${iqamahTime.toIso8601String()}',
|
||||
);
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_scheduled',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': 'iqamah',
|
||||
'prayer': prayerName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel all pending notifications.
|
||||
Future<void> cancelAll() async {
|
||||
await _plugin.cancelAll();
|
||||
DateTime? _parseScheduleDateTime(DateTime date, String hhmm) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})').firstMatch(hhmm);
|
||||
if (match == null) return null;
|
||||
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return DateTime(date.year, date.month, date.day, hour, minute);
|
||||
}
|
||||
|
||||
String? _canonicalPrayerKey(String scheduleKey) {
|
||||
switch (scheduleKey) {
|
||||
case 'subuh':
|
||||
case 'fajr':
|
||||
return 'fajr';
|
||||
case 'dzuhur':
|
||||
case 'dhuhr':
|
||||
return 'dhuhr';
|
||||
case 'ashar':
|
||||
case 'asr':
|
||||
return 'asr';
|
||||
case 'maghrib':
|
||||
return 'maghrib';
|
||||
case 'isya':
|
||||
case 'isha':
|
||||
return 'isha';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedPrayerName(String canonicalPrayerKey) {
|
||||
switch (canonicalPrayerKey) {
|
||||
case 'fajr':
|
||||
return 'Subuh';
|
||||
case 'dhuhr':
|
||||
return 'Dzuhur';
|
||||
case 'asr':
|
||||
return 'Ashar';
|
||||
case 'maghrib':
|
||||
return 'Maghrib';
|
||||
case 'isha':
|
||||
return 'Isya';
|
||||
default:
|
||||
return canonicalPrayerKey;
|
||||
}
|
||||
}
|
||||
|
||||
int _notificationId({
|
||||
required String cityId,
|
||||
required String dateKey,
|
||||
required String prayerKey,
|
||||
required bool isIqamah,
|
||||
}) {
|
||||
final seed = '$cityId|$dateKey|$prayerKey|${isIqamah ? 'iqamah' : 'adhan'}';
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 37 * hash + rune;
|
||||
}
|
||||
final bounded = hash.abs() % 700000;
|
||||
return isIqamah ? bounded + 800000 : bounded + 100000;
|
||||
}
|
||||
|
||||
String _buildSyncSignature(
|
||||
String cityId,
|
||||
Map<String, bool> adhanEnabled,
|
||||
Map<String, int> iqamahOffset,
|
||||
Map<String, Map<String, String>> schedulesByDate,
|
||||
) {
|
||||
final sortedAdhan = adhanEnabled.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
final sortedIqamah = iqamahOffset.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
final sortedDates = schedulesByDate.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
final buffer = StringBuffer(cityId);
|
||||
for (final e in sortedAdhan) {
|
||||
buffer.write('|${e.key}:${e.value ? 1 : 0}');
|
||||
}
|
||||
for (final e in sortedIqamah) {
|
||||
buffer.write('|${e.key}:${e.value}');
|
||||
}
|
||||
for (final dateEntry in sortedDates) {
|
||||
buffer.write('|${dateEntry.key}');
|
||||
final times = dateEntry.value.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final t in times) {
|
||||
buffer.write('|${t.key}:${t.value}');
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<void> cancelAllPending() async {
|
||||
try {
|
||||
await _plugin.cancelAllPendingNotifications();
|
||||
} catch (_) {
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> pendingCount() async {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
Future<void> syncHabitNotifications({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) {
|
||||
await cancelChecklistReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
final reminderTime = settings.checklistReminderTime ?? '09:00';
|
||||
final parts = _parseHourMinute(reminderTime);
|
||||
if (parts == null) {
|
||||
await cancelChecklistReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
parts.$1,
|
||||
parts.$2,
|
||||
);
|
||||
if (!target.isAfter(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id: _checklistReminderId,
|
||||
title: 'Checklist Ibadah Harian',
|
||||
body: 'Jangan lupa perbarui progres ibadah hari ini.',
|
||||
scheduledDate: tz.TZDateTime.from(target, tz.local),
|
||||
notificationDetails: _habitDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: 'checklist|daily|${target.toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelChecklistReminder() async {
|
||||
await _plugin.cancel(id: _checklistReminderId);
|
||||
}
|
||||
|
||||
int nonPrayerNotificationId(String seed) {
|
||||
var hash = 17;
|
||||
for (final rune in seed.runes) {
|
||||
hash = 41 * hash + rune;
|
||||
}
|
||||
return 900000 + (hash.abs() % 80000);
|
||||
}
|
||||
|
||||
Future<bool> showNonPrayerAlert({
|
||||
required AppSettings settings,
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String payloadType = 'system',
|
||||
bool silent = false,
|
||||
bool bypassQuietHours = false,
|
||||
bool bypassDailyCap = false,
|
||||
}) async {
|
||||
await init();
|
||||
|
||||
final runtime = NotificationRuntimeService.instance;
|
||||
if (!settings.alertsEnabled) return false;
|
||||
if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false;
|
||||
if (!bypassDailyCap &&
|
||||
runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: silent ? _systemDetails : _habitDetails,
|
||||
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
|
||||
if (!bypassDailyCap) {
|
||||
await runtime.incrementNonPrayerPushCount();
|
||||
}
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_push_fired',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': payloadType,
|
||||
'channel': 'push',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<NotificationPermissionStatus> getPermissionStatus() async {
|
||||
await init();
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final notificationsAllowed =
|
||||
await androidPlugin?.areNotificationsEnabled() ?? true;
|
||||
final exactAlarmAllowed =
|
||||
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: notificationsAllowed,
|
||||
exactAlarmAllowed: exactAlarmAllowed,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
final options = await iosPlugin?.checkPermissions();
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: options?.isEnabled ?? true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final macPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin>();
|
||||
final options = await macPlugin?.checkPermissions();
|
||||
return NotificationPermissionStatus(
|
||||
notificationsAllowed: options?.isEnabled ?? true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback to non-blocking defaults if platform query fails.
|
||||
}
|
||||
|
||||
return const NotificationPermissionStatus(
|
||||
notificationsAllowed: true,
|
||||
exactAlarmAllowed: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<NotificationPendingAlert>> pendingAlerts() async {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
final alerts = pending.map(_mapPendingRequest).toList()
|
||||
..sort((a, b) {
|
||||
final aTime = a.scheduledAt;
|
||||
final bTime = b.scheduledAt;
|
||||
if (aTime == null && bTime == null) return a.id.compareTo(b.id);
|
||||
if (aTime == null) return 1;
|
||||
if (bTime == null) return -1;
|
||||
return aTime.compareTo(bTime);
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) {
|
||||
final payload = raw.payload ?? '';
|
||||
final parts = payload.split('|');
|
||||
if (parts.length >= 3) {
|
||||
final type = parts[0].trim().toLowerCase();
|
||||
final title = raw.title ?? '${_labelForType(type)} • ${parts[1].trim()}';
|
||||
final body = raw.body ?? '';
|
||||
final scheduledAt = DateTime.tryParse(parts[2].trim());
|
||||
return NotificationPendingAlert(
|
||||
id: raw.id,
|
||||
type: type,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledAt: scheduledAt,
|
||||
);
|
||||
}
|
||||
|
||||
final fallbackType = _inferTypeFromTitle(raw.title ?? '');
|
||||
return NotificationPendingAlert(
|
||||
id: raw.id,
|
||||
type: fallbackType,
|
||||
title: raw.title ?? 'Pengingat',
|
||||
body: raw.body ?? '',
|
||||
scheduledAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _inferTypeFromTitle(String title) {
|
||||
final normalized = title.toLowerCase();
|
||||
if (normalized.contains('iqamah')) return 'iqamah';
|
||||
if (normalized.contains('adzan')) return 'adhan';
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
String _labelForType(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return 'Adzan';
|
||||
case 'iqamah':
|
||||
return 'Iqamah';
|
||||
case 'checklist':
|
||||
return 'Checklist';
|
||||
case 'streak_risk':
|
||||
return 'Streak';
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
default:
|
||||
return 'Pengingat';
|
||||
}
|
||||
}
|
||||
|
||||
(int, int)? _parseHourMinute(String hhmm) {
|
||||
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim());
|
||||
if (match == null) return null;
|
||||
final hour = int.tryParse(match.group(1) ?? '');
|
||||
final minute = int.tryParse(match.group(2) ?? '');
|
||||
if (hour == null || minute == null) return null;
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return (hour, minute);
|
||||
}
|
||||
}
|
||||
|
||||
104
lib/data/services/remote_notification_content_service.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
import 'notification_runtime_service.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Pulls server-defined notification content and maps it to local inbox items.
|
||||
class RemoteNotificationContentService {
|
||||
RemoteNotificationContentService._();
|
||||
static final RemoteNotificationContentService instance =
|
||||
RemoteNotificationContentService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
final NotificationRuntimeService _runtime =
|
||||
NotificationRuntimeService.instance;
|
||||
|
||||
Future<void> sync({
|
||||
required AppSettings settings,
|
||||
}) async {
|
||||
if (!settings.inboxEnabled) return;
|
||||
|
||||
final endpoint = (dotenv.env['NOTIFICATION_FEED_URL'] ?? '').trim();
|
||||
if (endpoint.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final lastSync = _runtime.lastRemoteSyncAt();
|
||||
if (lastSync != null &&
|
||||
now.difference(lastSync) < const Duration(hours: 6)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await http.get(Uri.parse(endpoint));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return;
|
||||
|
||||
final decoded = json.decode(response.body);
|
||||
final items = _extractItems(decoded);
|
||||
if (items.isEmpty) return;
|
||||
|
||||
for (final raw in items) {
|
||||
final id = (raw['id'] ?? '').toString().trim();
|
||||
final title = (raw['title'] ?? '').toString().trim();
|
||||
final body = (raw['body'] ?? '').toString().trim();
|
||||
if (id.isEmpty || title.isEmpty || body.isEmpty) continue;
|
||||
|
||||
final deeplink = (raw['deeplink'] ?? '').toString().trim();
|
||||
final type = (raw['type'] ?? 'content').toString().trim();
|
||||
final expiresAt =
|
||||
DateTime.tryParse((raw['expiresAt'] ?? '').toString().trim());
|
||||
final isPinned = raw['isPinned'] == true;
|
||||
final shouldPush = raw['push'] == true;
|
||||
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type.isEmpty ? 'content' : type,
|
||||
source: 'remote',
|
||||
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||
dedupeKey: 'remote.$id',
|
||||
expiresAt: expiresAt,
|
||||
isPinned: isPinned,
|
||||
meta: <String, dynamic>{'remoteId': id},
|
||||
);
|
||||
|
||||
if (shouldPush && settings.alertsEnabled) {
|
||||
final notif = NotificationService.instance;
|
||||
await notif.showNonPrayerAlert(
|
||||
settings: settings,
|
||||
id: notif.nonPrayerNotificationId('remote.push.$id'),
|
||||
title: title,
|
||||
body: body,
|
||||
payloadType: 'content',
|
||||
silent: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _runtime.setLastRemoteSyncAt(now);
|
||||
} catch (_) {
|
||||
// Non-fatal: remote feed is optional.
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractItems(dynamic decoded) {
|
||||
if (decoded is List) {
|
||||
return decoded.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||
}
|
||||
if (decoded is Map) {
|
||||
final list = decoded['items'];
|
||||
if (list is List) {
|
||||
return list.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||
}
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toStringKeyedMap(Map raw) {
|
||||
return raw.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
}
|
||||
47
lib/data/services/remote_push_service.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import '../local/models/app_settings.dart';
|
||||
import 'notification_inbox_service.dart';
|
||||
|
||||
/// Phase-4 bridge for future FCM/APNs wiring.
|
||||
///
|
||||
/// This app currently ships without Firebase/APNs SDK setup in source control.
|
||||
/// Once push SDK is configured, route incoming payloads to [ingestPayload].
|
||||
class RemotePushService {
|
||||
RemotePushService._();
|
||||
static final RemotePushService instance = RemotePushService._();
|
||||
|
||||
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||
|
||||
Future<void> init() async {
|
||||
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
|
||||
}
|
||||
|
||||
Future<void> ingestPayload(
|
||||
Map<String, dynamic> payload, {
|
||||
AppSettings? settings,
|
||||
}) async {
|
||||
if (settings != null && !settings.inboxEnabled) return;
|
||||
|
||||
final id = (payload['id'] ?? payload['messageId'] ?? '').toString().trim();
|
||||
final title = (payload['title'] ?? '').toString().trim();
|
||||
final body = (payload['body'] ?? '').toString().trim();
|
||||
if (id.isEmpty || title.isEmpty || body.isEmpty) return;
|
||||
|
||||
final type = (payload['type'] ?? 'content').toString().trim();
|
||||
final deeplink = (payload['deeplink'] ?? '').toString().trim();
|
||||
final expiresAt =
|
||||
DateTime.tryParse((payload['expiresAt'] ?? '').toString().trim());
|
||||
final isPinned = payload['isPinned'] == true;
|
||||
|
||||
await _inbox.addItem(
|
||||
title: title,
|
||||
body: body,
|
||||
type: type.isEmpty ? 'content' : type,
|
||||
source: 'remote',
|
||||
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||
dedupeKey: 'remote.push.$id',
|
||||
expiresAt: expiresAt,
|
||||
isPinned: isPinned,
|
||||
meta: <String, dynamic>{'remoteId': id},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
@@ -27,7 +28,13 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
late Box<AppSettings> _settingsBox;
|
||||
late AppSettings _settings;
|
||||
|
||||
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
|
||||
final List<String> _fardhuPrayers = [
|
||||
'Subuh',
|
||||
'Dzuhur',
|
||||
'Ashar',
|
||||
'Maghrib',
|
||||
'Isya'
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -45,7 +52,7 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
for (final p in _fardhuPrayers) {
|
||||
shalatLogs[p.toLowerCase()] = ShalatLog();
|
||||
}
|
||||
|
||||
|
||||
_logBox.put(
|
||||
_todayKey,
|
||||
DailyWorshipLog(
|
||||
@@ -69,7 +76,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
final log = _todayLog;
|
||||
|
||||
// Lazily attach Dzikir and Puasa if user toggles them mid-day
|
||||
if (_settings.trackDzikir && log.dzikirLog == null) log.dzikirLog = DzikirLog();
|
||||
if (_settings.trackDzikir && log.dzikirLog == null)
|
||||
log.dzikirLog = DzikirLog();
|
||||
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
|
||||
|
||||
int total = 0;
|
||||
@@ -155,17 +163,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
Text(
|
||||
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -246,14 +253,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.star, color: AppColors.primary, size: 14),
|
||||
const Icon(LucideIcons.star,
|
||||
color: AppColors.primary, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${log.totalPoints} pts',
|
||||
@@ -334,7 +343,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
border: Border.all(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Theme(
|
||||
@@ -347,10 +358,14 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(LucideIcons.building, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
child: Icon(LucideIcons.building,
|
||||
size: 22,
|
||||
color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
title: Text(
|
||||
'Sholat $prayerName',
|
||||
@@ -362,7 +377,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
),
|
||||
),
|
||||
subtitle: log.location != null
|
||||
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
? Text('Di ${log.location}',
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
: null,
|
||||
trailing: _CustomCheckbox(
|
||||
value: isCompleted,
|
||||
@@ -371,14 +388,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
childrenPadding:
|
||||
const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
children: [
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// Location Radio
|
||||
Row(
|
||||
children: [
|
||||
const Text('Pelaksanaan:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const Text('Pelaksanaan:',
|
||||
style:
|
||||
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Masjid', log, () {
|
||||
log.location = 'Masjid';
|
||||
@@ -422,7 +442,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
color: selected ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -453,7 +475,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
border: Border.all(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -467,10 +491,15 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(LucideIcons.bookOpen, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
child: Icon(LucideIcons.bookOpen,
|
||||
size: 22,
|
||||
color:
|
||||
log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -482,13 +511,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
|
||||
color: log.isCompleted && isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: null,
|
||||
decoration:
|
||||
log.isCompleted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Target: ${log.targetValue} ${log.targetUnit}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.primary),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -516,14 +549,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (log.autoSync)
|
||||
Tooltip(
|
||||
message: 'Sinkron dari Al-Quran',
|
||||
child: Icon(LucideIcons.refreshCw, size: 16, color: AppColors.primary),
|
||||
child: Icon(LucideIcons.refreshCw,
|
||||
size: 16, color: AppColors.primary),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.minusCircle, size: 20),
|
||||
@@ -536,7 +572,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.plusCircle, size: 20, color: AppColors.primary),
|
||||
icon: const Icon(LucideIcons.plusCircle,
|
||||
size: 20, color: AppColors.primary),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
log.rawAyatRead++;
|
||||
@@ -568,7 +605,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
const Text('Dzikir Harian',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -599,13 +637,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
children: [
|
||||
const Icon(LucideIcons.moonStar, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
const Expanded(
|
||||
child: Text('Puasa Sunnah',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
DropdownButton<String>(
|
||||
value: log.jenisPuasa,
|
||||
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
|
||||
underline: const SizedBox(),
|
||||
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 13))))
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
log.jenisPuasa = v;
|
||||
@@ -644,7 +686,9 @@ class _CustomCheckbox extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: value ? null : Border.all(color: Colors.grey, width: 2),
|
||||
),
|
||||
child: value ? const Icon(LucideIcons.check, size: 16, color: Colors.white) : null,
|
||||
child: value
|
||||
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/notification_orchestrator_service.dart';
|
||||
import '../../../data/services/notification_event_producer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/notification_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
@@ -25,7 +30,8 @@ class DaySchedule {
|
||||
final String province;
|
||||
final String date; // yyyy-MM-dd
|
||||
final String tanggal; // formatted date from API
|
||||
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
final Map<String, String>
|
||||
times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
@@ -65,7 +71,8 @@ class DaySchedule {
|
||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||
} else {
|
||||
for (int i = 0; i < prayers.length; i++) {
|
||||
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
|
||||
if (prayers[i].time != '-' &&
|
||||
prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -135,10 +142,10 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
// All prayers passed, fetch tomorrow's schedule
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||
|
||||
final tmrwJadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
|
||||
final tmrwJadwal = await MyQuranSholatService.instance
|
||||
.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
@@ -152,37 +159,106 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
unawaited(_syncAdhanNotifications(cityId, schedule));
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// Fallback to adhan package
|
||||
final position = await LocationService.instance.getCurrentLocation();
|
||||
double lat = position?.latitude ?? -6.2088;
|
||||
double lng = position?.longitude ?? 106.8456;
|
||||
final lat = position?.latitude ?? -6.2088;
|
||||
final lng = position?.longitude ?? 106.8456;
|
||||
final locationUnavailable = position == null;
|
||||
|
||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
if (result != null) {
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
return DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
final result =
|
||||
PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
final fallbackSchedule = DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak':
|
||||
timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha':
|
||||
timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
);
|
||||
unawaited(
|
||||
NotificationEventProducerService.instance.emitScheduleFallback(
|
||||
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
|
||||
AppSettings(),
|
||||
cityId: cityId,
|
||||
locationUnavailable: locationUnavailable,
|
||||
),
|
||||
);
|
||||
unawaited(_syncAdhanNotifications(cityId, fallbackSchedule));
|
||||
return fallbackSchedule;
|
||||
});
|
||||
|
||||
Future<void> _syncAdhanNotifications(
|
||||
String cityId, DaySchedule schedule) async {
|
||||
try {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
final adhanEnabled = settings.adhanEnabled.values.any((v) => v);
|
||||
|
||||
if (adhanEnabled) {
|
||||
final permissionStatus =
|
||||
await NotificationService.instance.getPermissionStatus();
|
||||
await NotificationEventProducerService.instance
|
||||
.emitPermissionWarningsIfNeeded(
|
||||
settings: settings,
|
||||
permissionStatus: permissionStatus,
|
||||
);
|
||||
}
|
||||
|
||||
final schedulesByDate = <String, Map<String, String>>{
|
||||
schedule.date: schedule.times,
|
||||
};
|
||||
|
||||
final baseDate = DateTime.tryParse(schedule.date);
|
||||
if (baseDate != null) {
|
||||
final nextDate = DateFormat('yyyy-MM-dd')
|
||||
.format(baseDate.add(const Duration(days: 1)));
|
||||
if (!schedulesByDate.containsKey(nextDate)) {
|
||||
final nextSchedule = await MyQuranSholatService.instance
|
||||
.getDailySchedule(cityId, nextDate);
|
||||
if (nextSchedule != null) {
|
||||
schedulesByDate[nextDate] = nextSchedule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await NotificationService.instance.syncPrayerNotifications(
|
||||
cityId: cityId,
|
||||
adhanEnabled: settings.adhanEnabled,
|
||||
iqamahOffset: settings.iqamahOffset,
|
||||
schedulesByDate: schedulesByDate,
|
||||
);
|
||||
await NotificationService.instance.syncHabitNotifications(
|
||||
settings: settings,
|
||||
);
|
||||
await NotificationOrchestratorService.instance.runPassivePass(
|
||||
settings: settings,
|
||||
);
|
||||
} catch (_) {
|
||||
// Don't block UI when scheduling notifications fails.
|
||||
unawaited(
|
||||
NotificationEventProducerService.instance.emitNotificationSyncFailed(
|
||||
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
|
||||
AppSettings(),
|
||||
cityId: cityId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
@@ -200,7 +276,7 @@ final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
|
||||
277
lib/features/doa/presentation/doa_screen.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class DoaScreen extends StatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
const DoaScreen({super.key, this.isSimpleModeTab = false});
|
||||
|
||||
@override
|
||||
State<DoaScreen> createState() => _DoaScreenState();
|
||||
}
|
||||
|
||||
class _DoaScreenState extends State<DoaScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _allDoa = [];
|
||||
List<Map<String, dynamic>> _filteredDoa = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDoa();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDoa() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await MuslimApiService.instance.getDoaList(strict: true);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allDoa = data;
|
||||
_filteredDoa = data;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allDoa = [];
|
||||
_filteredDoa = [];
|
||||
_loading = false;
|
||||
_error = 'Gagal memuat doa dari server';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final q = value.trim().toLowerCase();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _filteredDoa = _allDoa);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_filteredDoa = _allDoa.where((item) {
|
||||
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||
return title.contains(q) || indo.contains(q);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
void _showArabicFontSettings() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
if (!settings.isInBox) {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
double arabicFontSize = settings.arabicFontSize;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ukuran Font Arab'),
|
||||
Slider(
|
||||
value: arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
label: '${arabicFontSize.round()}pt',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (value) {
|
||||
setModalState(() => arabicFontSize = value);
|
||||
settings.arabicFontSize = value;
|
||||
if (settings.isInBox) {
|
||||
settings.save();
|
||||
} else {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text('Kumpulan Doa'),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadDoa,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi doa...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredDoa.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Doa tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredDoa.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredDoa[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
if ((item['source']
|
||||
?.toString()
|
||||
.isNotEmpty ??
|
||||
false)) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Sumber: ${item['source']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
293
lib/features/hadits/presentation/hadits_screen.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class HaditsScreen extends StatefulWidget {
|
||||
final bool isSimpleModeTab;
|
||||
const HaditsScreen({super.key, this.isSimpleModeTab = false});
|
||||
|
||||
@override
|
||||
State<HaditsScreen> createState() => _HaditsScreenState();
|
||||
}
|
||||
|
||||
class _HaditsScreenState extends State<HaditsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _allHadits = [];
|
||||
List<Map<String, dynamic>> _filteredHadits = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHadits();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadHadits() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final data = await MuslimApiService.instance.getHaditsList(strict: true);
|
||||
if (!mounted) return;
|
||||
data.sort((a, b) {
|
||||
final aa = (a['no'] as num?)?.toInt() ?? 0;
|
||||
final bb = (b['no'] as num?)?.toInt() ?? 0;
|
||||
return aa.compareTo(bb);
|
||||
});
|
||||
setState(() {
|
||||
_allHadits = data;
|
||||
_filteredHadits = data;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_allHadits = [];
|
||||
_filteredHadits = [];
|
||||
_loading = false;
|
||||
_error = 'Gagal memuat hadits dari server';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final q = value.trim().toLowerCase();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _filteredHadits = _allHadits);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_filteredHadits = _allHadits.where((item) {
|
||||
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||
return title.contains(q) || indo.contains(q);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
void _showArabicFontSettings() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
if (!settings.isInBox) {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
double arabicFontSize = settings.arabicFontSize;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pengaturan Tampilan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ukuran Font Arab'),
|
||||
Slider(
|
||||
value: arabicFontSize,
|
||||
min: 16,
|
||||
max: 40,
|
||||
divisions: 12,
|
||||
label: '${arabicFontSize.round()}pt',
|
||||
activeColor: AppColors.primary,
|
||||
onChanged: (value) {
|
||||
setModalState(() => arabicFontSize = value);
|
||||
settings.arabicFontSize = value;
|
||||
if (settings.isInBox) {
|
||||
settings.save();
|
||||
} else {
|
||||
settingsBox.put('default', settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||
title: const Text("Hadits Arba'in"),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadHadits,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari judul atau isi hadits...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredHadits.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Hadits tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _filteredHadits.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _filteredHadits[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius:
|
||||
BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${item['no'] ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item['judul']?.toString() ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
@@ -56,8 +57,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final daysInMonth = DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
@@ -102,7 +102,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
insetPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
@@ -123,7 +124,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
@@ -133,21 +134,23 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (val.trim().length < 3) return;
|
||||
|
||||
|
||||
if (debounce?.isActive ?? false) debounce!.cancel();
|
||||
debounce = Timer(const Duration(milliseconds: 500), () async {
|
||||
debounce =
|
||||
Timer(const Duration(milliseconds: 500), () async {
|
||||
if (!mounted) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
|
||||
|
||||
try {
|
||||
final res = await MyQuranSholatService.instance.searchCity(val.trim());
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error searching city: $e');
|
||||
debugPrint('Error searching city: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
@@ -175,7 +178,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
const Text('Tidak ada hasil',
|
||||
style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
@@ -193,11 +197,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_settings.save();
|
||||
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
@@ -224,9 +228,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
const tableBottomSpacing = 28.0;
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final monthArg =
|
||||
'${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
@@ -235,10 +241,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
title: const Text('Kalender Sholat'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -266,7 +269,9 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
@@ -306,7 +311,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
color:
|
||||
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
@@ -314,40 +320,40 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(LucideIcons.chevronDown,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
Icon(LucideIcons.chevronDown,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
@@ -378,7 +384,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
tableBottomSpacing,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
@@ -453,7 +464,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
tableBottomSpacing,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
@@ -536,7 +552,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
|
||||
@@ -5,11 +5,10 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/local/models/checklist_item.dart';
|
||||
|
||||
class LaporanScreen extends ConsumerStatefulWidget {
|
||||
const LaporanScreen({super.key});
|
||||
@@ -74,48 +73,60 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||
final log = logBox.get(key);
|
||||
|
||||
|
||||
if (log != null && log.totalItems > 0) {
|
||||
daysChecked++;
|
||||
|
||||
|
||||
// Fardhu
|
||||
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
|
||||
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
|
||||
int completedFardhu =
|
||||
log.shalatLogs.values.where((l) => l.completed).length;
|
||||
completionCounts['fardhu'] =
|
||||
(completionCounts['fardhu'] ?? 0) + completedFardhu;
|
||||
|
||||
// Rawatib
|
||||
int rawatibTotal = 0;
|
||||
int rawatibCompleted = 0;
|
||||
for (var sLog in log.shalatLogs.values) {
|
||||
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
|
||||
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
|
||||
if (sLog.qabliyah != null) {
|
||||
rawatibTotal++;
|
||||
if (sLog.qabliyah!) rawatibCompleted++;
|
||||
}
|
||||
if (sLog.badiyah != null) {
|
||||
rawatibTotal++;
|
||||
if (sLog.badiyah!) rawatibCompleted++;
|
||||
}
|
||||
}
|
||||
if (rawatibTotal > 0) {
|
||||
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
||||
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
||||
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
||||
completionCounts['rawatib'] =
|
||||
(completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
||||
}
|
||||
|
||||
// Tilawah
|
||||
if (log.tilawahLog != null) {
|
||||
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
||||
if (log.tilawahLog!.isCompleted) {
|
||||
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
|
||||
}
|
||||
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
||||
if (log.tilawahLog!.isCompleted) {
|
||||
completionCounts['tilawah'] =
|
||||
(completionCounts['tilawah'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dzikir
|
||||
if (log.dzikirLog != null) {
|
||||
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
||||
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
||||
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
|
||||
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
||||
int dCompleted =
|
||||
(log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
||||
completionCounts['dzikir'] =
|
||||
(completionCounts['dzikir'] ?? 0) + dCompleted;
|
||||
}
|
||||
|
||||
// Puasa
|
||||
if (log.puasaLog != null) {
|
||||
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
|
||||
if (log.puasaLog!.completed) {
|
||||
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
|
||||
}
|
||||
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
|
||||
if (log.puasaLog!.completed) {
|
||||
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +181,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final isSimpleMode = settingsBox.get('default')?.simpleMode ?? false;
|
||||
|
||||
@@ -180,10 +191,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
title: const Text('Riwayat Ibadah'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -204,10 +212,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
title: const Text('Laporan Kualitas Ibadah'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
@@ -253,7 +258,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
|
||||
_buildWeeklyView(
|
||||
context, isDark, weekData, avgPercent, insights),
|
||||
_buildComingSoon(context, 'Bulanan'),
|
||||
_buildComingSoon(context, 'Tahunan'),
|
||||
],
|
||||
@@ -332,57 +338,71 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
const SizedBox(height: 20),
|
||||
// ── Bar Chart ──
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: weekData.map((d) {
|
||||
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 120 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: AppColors.primary
|
||||
.withValues(alpha: 0.3 + ratio * 0.4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
height: 162,
|
||||
child: Builder(builder: (context) {
|
||||
final maxPts = weekData
|
||||
.map((d) => d.value)
|
||||
.fold<double>(0.0, (a, b) => a > b ? a : b)
|
||||
.clamp(50.0, 300.0);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: weekData.map((d) {
|
||||
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${d.value.round()}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
d.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: d.isToday
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 120 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
: AppColors.primary.withValues(
|
||||
alpha: 0.3 + ratio * 0.4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
d.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: d.isToday
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -587,9 +607,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.history, size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
|
||||
Icon(LucideIcons.history,
|
||||
size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Belum ada riwayat ibadah', style: TextStyle(color: AppColors.sage)),
|
||||
const Text('Belum ada riwayat ibadah',
|
||||
style: TextStyle(color: AppColors.sage)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -602,10 +624,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
final isToday = log.date == DateFormat('yyyy-MM-dd').format(now);
|
||||
|
||||
|
||||
// Build summary text
|
||||
final List<String> finished = [];
|
||||
int fardhuCount = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
int fardhuCount =
|
||||
log.shalatLogs.values.where((l) => l.completed).length;
|
||||
if (fardhuCount > 0) finished.add('$fardhuCount Fardhu');
|
||||
if (log.tilawahLog?.isCompleted == true) finished.add('Tilawah');
|
||||
if (log.dzikirLog != null) {
|
||||
@@ -635,7 +658,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(LucideIcons.checkCircle2, color: AppColors.primary),
|
||||
child: const Icon(LucideIcons.checkCircle2,
|
||||
color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -643,7 +667,10 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isToday ? 'Hari Ini' : DateFormat('EEEE, d MMM yyyy').format(DateTime.parse(log.date)),
|
||||
isToday
|
||||
? 'Hari Ini'
|
||||
: DateFormat('EEEE, d MMM yyyy')
|
||||
.format(DateTime.parse(log.date)),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
@@ -651,10 +678,14 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
finished.isNotEmpty ? finished.join(' • ') : 'Belum ada aktivitas',
|
||||
finished.isNotEmpty
|
||||
? finished.join(' • ')
|
||||
: 'Belum ada aktivitas',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../app/icons/app_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/services/notification_analytics_service.dart';
|
||||
import '../../../data/services/notification_inbox_service.dart';
|
||||
import '../../../data/services/notification_service.dart';
|
||||
|
||||
class NotificationCenterScreen extends StatefulWidget {
|
||||
const NotificationCenterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
_NotificationCenterScreenState();
|
||||
}
|
||||
|
||||
class _NotificationCenterScreenState extends State<NotificationCenterScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
late Future<List<NotificationPendingAlert>> _alarmsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
unawaited(NotificationInboxService.instance.removeByType('prayer'));
|
||||
_alarmsFuture = NotificationService.instance.pendingAlerts();
|
||||
NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_opened',
|
||||
dimensions: const <String, dynamic>{'screen': 'notification_center'},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshAlarms() async {
|
||||
setState(() {
|
||||
_alarmsFuture = NotificationService.instance.pendingAlerts();
|
||||
});
|
||||
await _alarmsFuture;
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
await NotificationInboxService.instance.markAllRead();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Semua pesan sudah ditandai terbaca.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final inboxListenable = NotificationInboxService.instance.listenable();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const AppIcon(glyph: AppIcons.backArrow),
|
||||
),
|
||||
title: const Text('Pemberitahuan'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
ListenableBuilder(
|
||||
listenable: _tabController.animation!,
|
||||
builder: (context, _) {
|
||||
final tabIndex = _tabController.index;
|
||||
if (tabIndex == 0) {
|
||||
return IconButton(
|
||||
onPressed: _refreshAlarms,
|
||||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inboxListenable,
|
||||
builder: (context, _, __) {
|
||||
final unread =
|
||||
NotificationInboxService.instance.unreadCount();
|
||||
if (unread <= 0) return const SizedBox.shrink();
|
||||
return TextButton(
|
||||
onPressed: _markAllRead,
|
||||
child: const Text('Tandai semua'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
tabs: [
|
||||
FutureBuilder<List<NotificationPendingAlert>>(
|
||||
future: _alarmsFuture,
|
||||
builder: (context, snapshot) {
|
||||
final count = snapshot.data?.length ?? 0;
|
||||
return Tab(text: count > 0 ? 'Alarm ($count)' : 'Alarm');
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: inboxListenable,
|
||||
builder: (context, _, __) {
|
||||
final unread = NotificationInboxService.instance.unreadCount();
|
||||
return Tab(text: unread > 0 ? 'Pesan ($unread)' : 'Pesan');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_AlarmTab(future: _alarmsFuture, onRefresh: _refreshAlarms),
|
||||
_InboxTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlarmTab extends StatefulWidget {
|
||||
const _AlarmTab({
|
||||
required this.future,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final Future<List<NotificationPendingAlert>> future;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
@override
|
||||
State<_AlarmTab> createState() => _AlarmTabState();
|
||||
}
|
||||
|
||||
class _AlarmTabState extends State<_AlarmTab> {
|
||||
String _alarmFilter = 'upcoming';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return FutureBuilder<List<NotificationPendingAlert>>(
|
||||
future: widget.future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final alarms = snapshot.data ?? const <NotificationPendingAlert>[];
|
||||
final now = DateTime.now();
|
||||
final upcoming = alarms
|
||||
.where((alarm) =>
|
||||
alarm.scheduledAt == null || !alarm.scheduledAt!.isBefore(now))
|
||||
.toList();
|
||||
final passed = alarms
|
||||
.where((alarm) =>
|
||||
alarm.scheduledAt != null && alarm.scheduledAt!.isBefore(now))
|
||||
.toList();
|
||||
final visible = _alarmFilter == 'past' ? passed : upcoming;
|
||||
|
||||
if (alarms.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.onRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildAlarmFilters(
|
||||
isDark: isDark,
|
||||
upcomingCount: 0,
|
||||
passedCount: 0,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 40,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Belum ada alarm aktif',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Alarm adzan dan iqamah akan muncul di sini saat sudah dijadwalkan.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: widget.onRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildAlarmFilters(
|
||||
isDark: isDark,
|
||||
upcomingCount: upcoming.length,
|
||||
passedCount: passed.length,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (visible.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.14)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_alarmFilter == 'upcoming'
|
||||
? 'Tidak ada alarm akan datang.'
|
||||
: 'Belum ada alarm sudah lewat.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final alarm in visible) ...[
|
||||
_buildAlarmItem(context, isDark: isDark, alarm: alarm),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlarmItem(
|
||||
BuildContext context, {
|
||||
required bool isDark,
|
||||
required NotificationPendingAlert alarm,
|
||||
}) {
|
||||
final chipColor = _chipColor(alarm.type);
|
||||
final when = _formatAlarmTime(alarm.scheduledAt);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.16)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: chipColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 18,
|
||||
color: chipColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
alarm.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (alarm.body.isNotEmpty)
|
||||
Text(
|
||||
alarm.body,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_TypeBadge(
|
||||
label: _alarmLabel(alarm.type),
|
||||
color: chipColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
when,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlarmFilters({
|
||||
required bool isDark,
|
||||
required int upcomingCount,
|
||||
required int passedCount,
|
||||
}) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: upcomingCount > 0
|
||||
? 'Akan Datang ($upcomingCount)'
|
||||
: 'Akan Datang',
|
||||
selected: _alarmFilter == 'upcoming',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _alarmFilter = 'upcoming'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: passedCount > 0 ? 'Sudah Lewat ($passedCount)' : 'Sudah Lewat',
|
||||
selected: _alarmFilter == 'past',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _alarmFilter = 'past'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _chipColor(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return AppColors.primary;
|
||||
case 'iqamah':
|
||||
return const Color(0xFF7B61FF);
|
||||
case 'checklist':
|
||||
return const Color(0xFF2D98DA);
|
||||
case 'system':
|
||||
return const Color(0xFFE17055);
|
||||
default:
|
||||
return AppColors.sage;
|
||||
}
|
||||
}
|
||||
|
||||
String _alarmLabel(String type) {
|
||||
switch (type) {
|
||||
case 'adhan':
|
||||
return 'Adzan';
|
||||
case 'iqamah':
|
||||
return 'Iqamah';
|
||||
case 'checklist':
|
||||
return 'Checklist';
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
default:
|
||||
return 'Alarm';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatAlarmTime(DateTime? value) {
|
||||
if (value == null) return 'Waktu tidak diketahui';
|
||||
try {
|
||||
return DateFormat('EEE, d MMM • HH:mm', 'id_ID').format(value);
|
||||
} catch (_) {
|
||||
return DateFormat('d/MM • HH:mm').format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InboxTab extends StatefulWidget {
|
||||
@override
|
||||
State<_InboxTab> createState() => _InboxTabState();
|
||||
}
|
||||
|
||||
class _InboxTabState extends State<_InboxTab> {
|
||||
String _filter = 'all';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final inbox = NotificationInboxService.instance;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: inbox.listenable(),
|
||||
builder: (context, _, __) {
|
||||
final items = inbox.allItems(filter: _filter);
|
||||
if (items.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildFilters(isDark),
|
||||
const SizedBox(height: 20),
|
||||
AppIcon(
|
||||
glyph: AppIcons.notification,
|
||||
size: 40,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Belum ada pesan',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Pesan sistem dan ringkasan ibadah akan muncul di sini.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_buildFilters(isDark),
|
||||
const SizedBox(height: 12),
|
||||
...items.map((item) {
|
||||
final accent = _inboxAccent(item.type);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
background: _swipeBackground(
|
||||
isDark: isDark,
|
||||
icon: item.isRead ? Icons.mark_email_unread : Icons.done,
|
||||
label: item.isRead ? 'Belum dibaca' : 'Tandai dibaca',
|
||||
alignment: Alignment.centerLeft,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
secondaryBackground: _swipeBackground(
|
||||
isDark: isDark,
|
||||
icon: Icons.delete_outline,
|
||||
label: 'Hapus',
|
||||
alignment: Alignment.centerRight,
|
||||
color: AppColors.errorLight,
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
if (item.isRead) {
|
||||
await inbox.markUnread(item.id);
|
||||
} else {
|
||||
await inbox.markRead(item.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await inbox.remove(item.id);
|
||||
return true;
|
||||
},
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () async {
|
||||
if (!item.isRead) {
|
||||
await inbox.markRead(item.id);
|
||||
}
|
||||
await NotificationAnalyticsService.instance.track(
|
||||
'notif_inbox_opened',
|
||||
dimensions: <String, dynamic>{
|
||||
'event_type': item.type,
|
||||
'deeplink': item.deeplink ?? '',
|
||||
},
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
final deeplink = item.deeplink;
|
||||
if (deeplink != null && deeplink.isNotEmpty) {
|
||||
if (deeplink.startsWith('/')) {
|
||||
context.go(deeplink);
|
||||
} else {
|
||||
context.push(deeplink);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: !item.isRead
|
||||
? accent.withValues(alpha: isDark ? 0.4 : 0.32)
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.14)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
glyph: _inboxGlyph(item.type),
|
||||
size: 18,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (item.isPinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.push_pin_rounded,
|
||||
size: 15,
|
||||
color: AppColors.navActiveGoldDeep,
|
||||
),
|
||||
),
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.body,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_TypeBadge(
|
||||
label: _inboxLabel(item.type),
|
||||
color: accent,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatInboxTime(item.createdAt),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => inbox.togglePinned(item.id),
|
||||
icon: Icon(
|
||||
item.isPinned
|
||||
? Icons.push_pin_rounded
|
||||
: Icons.push_pin_outlined,
|
||||
size: 18,
|
||||
color: item.isPinned
|
||||
? AppColors.navActiveGoldDeep
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(bool isDark) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: 'Semua',
|
||||
selected: _filter == 'all',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'all'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: 'Belum Dibaca',
|
||||
selected: _filter == 'unread',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'unread'),
|
||||
),
|
||||
_FilterChip(
|
||||
label: 'Sistem',
|
||||
selected: _filter == 'system',
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _filter = 'system'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swipeBackground({
|
||||
required bool isDark,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Alignment alignment,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppIconGlyph _inboxGlyph(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return AppIcons.settings;
|
||||
case 'summary':
|
||||
case 'streak_risk':
|
||||
return AppIcons.laporan;
|
||||
case 'prayer':
|
||||
return AppIcons.notification;
|
||||
case 'content':
|
||||
return AppIcons.notification;
|
||||
default:
|
||||
return AppIcons.notification;
|
||||
}
|
||||
}
|
||||
|
||||
Color _inboxAccent(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return const Color(0xFFE17055);
|
||||
case 'summary':
|
||||
return const Color(0xFF7B61FF);
|
||||
case 'prayer':
|
||||
return AppColors.primary;
|
||||
case 'content':
|
||||
return const Color(0xFF00CEC9);
|
||||
default:
|
||||
return AppColors.sage;
|
||||
}
|
||||
}
|
||||
|
||||
String _inboxLabel(String type) {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return 'Sistem';
|
||||
case 'summary':
|
||||
return 'Ringkasan';
|
||||
case 'streak_risk':
|
||||
return 'Pengingat';
|
||||
case 'prayer':
|
||||
return 'Sholat';
|
||||
case 'content':
|
||||
return 'Konten';
|
||||
default:
|
||||
return 'Pesan';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatInboxTime(DateTime value) {
|
||||
final now = DateTime.now();
|
||||
final isToday = now.year == value.year &&
|
||||
now.month == value.month &&
|
||||
now.day == value.day;
|
||||
try {
|
||||
if (isToday) {
|
||||
return DateFormat('HH:mm', 'id_ID').format(value);
|
||||
}
|
||||
return DateFormat('d MMM • HH:mm', 'id_ID').format(value);
|
||||
} catch (_) {
|
||||
if (isToday) {
|
||||
return DateFormat('HH:mm').format(value);
|
||||
}
|
||||
return DateFormat('d/MM • HH:mm').format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.isDark,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool isDark;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppColors.primary.withValues(alpha: isDark ? 0.22 : 0.16)
|
||||
: (isDark
|
||||
? AppColors.surfaceDarkElevated
|
||||
: AppColors.surfaceLightElevated),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
const _TypeBadge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||