Polish navigation, Quran flows, and sharing UX
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
Normal file
BIN
assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
Normal file
Binary file not shown.
BIN
assets/fonts/ScheherazadeNew-Bold.ttf
Normal file
BIN
assets/fonts/ScheherazadeNew-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ScheherazadeNew-Regular.ttf
Normal file
BIN
assets/fonts/ScheherazadeNew-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/UthmanTN1-Ver10.otf
Normal file
BIN
assets/fonts/UthmanTN1-Ver10.otf
Normal file
Binary file not shown.
BIN
assets/images/blob.png
Normal file
BIN
assets/images/blob.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
248
docs/notification-plan.md
Normal file
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?
|
||||
173
hugeicons-migration-spec.md
Normal file
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
|
||||
@@ -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"]];
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -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
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';
|
||||
@@ -19,6 +21,7 @@ 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).
|
||||
@@ -33,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(
|
||||
@@ -50,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(
|
||||
@@ -97,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(
|
||||
@@ -107,7 +120,8 @@ 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,
|
||||
initialQariId: qariId,
|
||||
@@ -139,7 +153,8 @@ final GoRouter appRouter = GoRouter(
|
||||
// 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(
|
||||
@@ -148,18 +163,24 @@ final GoRouter appRouter = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'enrichment',
|
||||
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||
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(
|
||||
@@ -168,7 +189,8 @@ 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,
|
||||
initialQariId: qariId,
|
||||
@@ -187,11 +209,17 @@ final GoRouter appRouter = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: '/hadits',
|
||||
builder: (context, state) => const HaditsScreen(isSimpleModeTab: true),
|
||||
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,
|
||||
@@ -201,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();
|
||||
@@ -214,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;
|
||||
@@ -243,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 {
|
||||
@@ -270,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.w400,
|
||||
height: 2.2,
|
||||
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
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
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
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
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
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 = [];
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -77,6 +77,33 @@ class AppSettings extends HiveObject {
|
||||
@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 = '',
|
||||
@@ -102,6 +129,15 @@ class AppSettings extends HiveObject {
|
||||
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,34 +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,
|
||||
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(24)
|
||||
..writeByte(33)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
@@ -95,7 +126,25 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(22)
|
||||
..write(obj.dzikirAutoAdvance)
|
||||
..writeByte(23)
|
||||
..write(obj.dzikirHapticOnCount);
|
||||
..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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class MuslimApiException implements Exception {
|
||||
@@ -138,7 +139,7 @@ class MuslimApiService {
|
||||
}
|
||||
|
||||
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
|
||||
final audioUrl = _asString(audioValue);
|
||||
final audioUrl = _extractAudioUrl(audioValue);
|
||||
if (audioUrl.isEmpty) return {};
|
||||
return {
|
||||
'01': audioUrl,
|
||||
@@ -150,6 +151,59 @@ class MuslimApiService {
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -165,20 +219,12 @@ class MuslimApiService {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
|
||||
final audio = _asString(item['audio']);
|
||||
return {
|
||||
'nomorAyat': _asInt(item['ayah']),
|
||||
'teksArab': _asString(item['arab']),
|
||||
'teksLatin': _asString(item['latin']),
|
||||
'teksIndonesia': _asString(item['text']),
|
||||
'audio': {
|
||||
'01': audio,
|
||||
'02': audio,
|
||||
'03': audio,
|
||||
'04': audio,
|
||||
'05': audio,
|
||||
'06': audio,
|
||||
},
|
||||
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
|
||||
'juz': _asInt(item['juz']),
|
||||
'page': _asInt(item['page']),
|
||||
'hizb': _asInt(item['hizb']),
|
||||
@@ -194,10 +240,8 @@ class MuslimApiService {
|
||||
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();
|
||||
_surahListCache =
|
||||
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
|
||||
return _surahListCache!;
|
||||
}
|
||||
|
||||
@@ -219,10 +263,8 @@ class MuslimApiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final mappedAyah = rawAyah
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(_mapAyah)
|
||||
.toList();
|
||||
final mappedAyah =
|
||||
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
|
||||
|
||||
final mapped = {
|
||||
...summary,
|
||||
@@ -257,11 +299,58 @@ class MuslimApiService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWordByWord(int surahId, int ayahId) async {
|
||||
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');
|
||||
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) {
|
||||
@@ -342,7 +431,8 @@ class MuslimApiService {
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -386,7 +476,8 @@ class MuslimApiService {
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
result.sort(
|
||||
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -449,12 +540,17 @@ class MuslimApiService {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
@@ -478,9 +574,8 @@ class MuslimApiService {
|
||||
|
||||
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');
|
||||
final raw =
|
||||
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
|
||||
if (raw is! List) {
|
||||
if (strict) {
|
||||
throw const MuslimApiException('Invalid doa payload');
|
||||
@@ -500,7 +595,8 @@ class MuslimApiService {
|
||||
return _doaCache!;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getHaditsList({bool strict = false}) async {
|
||||
Future<List<Map<String, dynamic>>> getHaditsList(
|
||||
{bool strict = false}) async {
|
||||
if (_haditsCache != null) return _haditsCache!;
|
||||
final raw = strict
|
||||
? await _getDataOrThrow('/v1/hadits')
|
||||
|
||||
39
lib/data/services/notification_analytics_service.dart
Normal file
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
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
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
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
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
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
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() {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -136,8 +143,8 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
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);
|
||||
@@ -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 =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
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 {
|
||||
@@ -70,6 +74,62 @@ class _DoaScreenState extends State<DoaScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -78,128 +138,139 @@ class _DoaScreenState extends State<DoaScreen> {
|
||||
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: 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),
|
||||
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(
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredDoa.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Doa tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
)
|
||||
: 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.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,
|
||||
? 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
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 {
|
||||
@@ -75,6 +79,62 @@ class _HaditsScreenState extends State<HaditsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -83,140 +143,150 @@ class _HaditsScreenState extends State<HaditsScreen> {
|
||||
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: 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),
|
||||
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(
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _filteredHadits.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Hadits tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: Text(
|
||||
item['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item['indo']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
)
|
||||
: 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.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
? 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;
|
||||
});
|
||||
@@ -135,19 +136,21 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
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,
|
||||
@@ -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});
|
||||
@@ -80,42 +79,54 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
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: [
|
||||
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),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -605,7 +627,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,18 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/arabic_text.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/quran_bookmark.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
|
||||
class QuranBookmarksScreen extends StatefulWidget {
|
||||
const QuranBookmarksScreen({super.key});
|
||||
final bool isSimpleModeTab;
|
||||
const QuranBookmarksScreen({
|
||||
super.key,
|
||||
this.isSimpleModeTab = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState();
|
||||
@@ -18,6 +24,8 @@ class QuranBookmarksScreen extends StatefulWidget {
|
||||
class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
bool _showLatin = true;
|
||||
bool _showTerjemahan = true;
|
||||
final Map<int, Future<Map<String, dynamic>?>> _surahFutureCache = {};
|
||||
final Map<dynamic, Future<_ResolvedBookmarkContent?>> _bookmarkFutureCache = {};
|
||||
|
||||
String _readingRoute(int surahId, int verseId) {
|
||||
final isSimple =
|
||||
@@ -39,13 +47,16 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
void _showDisplaySettings() {
|
||||
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: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -90,6 +101,59 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _getSurah(int surahId) {
|
||||
return _surahFutureCache.putIfAbsent(
|
||||
surahId,
|
||||
() => MuslimApiService.instance.getSurah(surahId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ResolvedBookmarkContent?> _loadResolvedBookmarkContent(
|
||||
QuranBookmark bookmark,
|
||||
) async {
|
||||
final surah = await _getSurah(bookmark.surahId);
|
||||
final verses = List<Map<String, dynamic>>.from(surah?['ayat'] ?? []);
|
||||
final verseIndex = bookmark.verseId - 1;
|
||||
if (verseIndex < 0 || verseIndex >= verses.length) return null;
|
||||
|
||||
final verse = verses[verseIndex];
|
||||
final resolved = _ResolvedBookmarkContent(
|
||||
verseText: verse['teksArab']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksArab'].toString().trim()
|
||||
: bookmark.verseText,
|
||||
verseLatin: verse['teksLatin']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksLatin'].toString().trim()
|
||||
: bookmark.verseLatin,
|
||||
verseTranslation:
|
||||
verse['teksIndonesia']?.toString().trim().isNotEmpty == true
|
||||
? verse['teksIndonesia'].toString().trim()
|
||||
: bookmark.verseTranslation,
|
||||
);
|
||||
|
||||
final needsUpdate = bookmark.verseText != resolved.verseText ||
|
||||
bookmark.verseLatin != resolved.verseLatin ||
|
||||
bookmark.verseTranslation != resolved.verseTranslation;
|
||||
|
||||
if (needsUpdate) {
|
||||
bookmark.verseText = resolved.verseText;
|
||||
bookmark.verseLatin = resolved.verseLatin;
|
||||
bookmark.verseTranslation = resolved.verseTranslation;
|
||||
await bookmark.save();
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Future<_ResolvedBookmarkContent?> _getResolvedBookmarkContent(
|
||||
QuranBookmark bookmark,
|
||||
) {
|
||||
final bookmarkKey = bookmark.key ?? '${bookmark.surahId}_${bookmark.verseId}';
|
||||
return _bookmarkFutureCache.putIfAbsent(
|
||||
bookmarkKey,
|
||||
() => _loadResolvedBookmarkContent(bookmark),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
@@ -105,94 +169,106 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||
builder: (context, Box<QuranBookmark> box, _) {
|
||||
if (box.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bookmark,
|
||||
size: 64,
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada markah',
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||
builder: (context, Box<QuranBookmark> box, _) {
|
||||
if (box.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bookmark,
|
||||
size: 64,
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada markah',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tandai ayat saat membaca Al-Quran',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter bookmarks
|
||||
final allBookmarks = box.values.toList();
|
||||
final lastRead = allBookmarks.where((b) => b.isLastRead).toList();
|
||||
final favorites = allBookmarks.where((b) => !b.isLastRead).toList()
|
||||
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (lastRead.isNotEmpty) ...[
|
||||
const Text(
|
||||
'TERAKHIR DIBACA',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tandai ayat saat membaca Al-Quran',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildBookmarkCard(context, lastRead.first, isDark, box,
|
||||
isLastRead: true),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
if (favorites.isNotEmpty) ...[
|
||||
const Text(
|
||||
'AYAT FAVORIT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...favorites.map((fav) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildBookmarkCard(context, fav, isDark, box,
|
||||
isLastRead: false),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Filter bookmarks
|
||||
final allBookmarks = box.values.toList();
|
||||
final lastRead = allBookmarks.where((b) => b.isLastRead).toList();
|
||||
final favorites = allBookmarks.where((b) => !b.isLastRead).toList()
|
||||
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (lastRead.isNotEmpty) ...[
|
||||
const Text(
|
||||
'TERAKHIR DIBACA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildBookmarkCard(context, lastRead.first, isDark, box, isLastRead: true),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
if (favorites.isNotEmpty) ...[
|
||||
const Text(
|
||||
'AYAT FAVORIT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...favorites.map((fav) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildBookmarkCard(context, fav, isDark, box, isLastRead: false),
|
||||
)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark, bool isDark, Box<QuranBookmark> box, {required bool isLastRead}) {
|
||||
Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark,
|
||||
bool isDark, Box<QuranBookmark> box,
|
||||
{required bool isLastRead}) {
|
||||
final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt);
|
||||
final resolvedFuture = _getResolvedBookmarkContent(bookmark);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
onTap: () =>
|
||||
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -202,16 +278,20 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
border: Border.all(
|
||||
color: isLastRead
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.1) : AppColors.cream),
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream),
|
||||
width: isLastRead ? 1.5 : 1.0,
|
||||
),
|
||||
boxShadow: isLastRead ? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
] : null,
|
||||
boxShadow: isLastRead
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -220,7 +300,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -229,7 +310,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLastRead) ...[
|
||||
const Icon(LucideIcons.pin, size: 12, color: AppColors.primary),
|
||||
const Icon(LucideIcons.pin,
|
||||
size: 12, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
@@ -244,7 +326,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 20),
|
||||
icon: const Icon(LucideIcons.trash2,
|
||||
color: Colors.red, size: 20),
|
||||
onPressed: () => box.delete(bookmark.key),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
@@ -252,76 +335,93 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
bookmark.verseText,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
FutureBuilder<_ResolvedBookmarkContent?>(
|
||||
future: resolvedFuture,
|
||||
builder: (context, snapshot) {
|
||||
final content = snapshot.data ??
|
||||
_ResolvedBookmarkContent(
|
||||
verseText: bookmark.verseText,
|
||||
verseLatin: bookmark.verseLatin,
|
||||
verseTranslation: bookmark.verseTranslation,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
content.verseText,
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
if (_showLatin && content.verseLatin != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
content.verseLatin!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_showTerjemahan &&
|
||||
content.verseTranslation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
content.verseTranslation!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (_showLatin && bookmark.verseLatin != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
bookmark.verseLatin!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_showTerjemahan && bookmark.verseTranslation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bookmark.verseTranslation!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isLastRead) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () =>
|
||||
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
onPressed: () => context
|
||||
.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
|
||||
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||
label: const Text('Lanjutkan Membaca'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.clock,
|
||||
size: 12,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${isLastRead ? 'Ditandai' : 'Disimpan'}: $dateStr',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -332,3 +432,15 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolvedBookmarkContent {
|
||||
const _ResolvedBookmarkContent({
|
||||
required this.verseText,
|
||||
this.verseLatin,
|
||||
this.verseTranslation,
|
||||
});
|
||||
|
||||
final String verseText;
|
||||
final String? verseLatin;
|
||||
final String? verseTranslation;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 QuranEnrichmentScreen extends StatefulWidget {
|
||||
const QuranEnrichmentScreen({super.key});
|
||||
final bool isSimpleModeTab;
|
||||
const QuranEnrichmentScreen({
|
||||
super.key,
|
||||
this.isSimpleModeTab = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuranEnrichmentScreen> createState() => _QuranEnrichmentScreenState();
|
||||
@@ -15,12 +23,12 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
late TabController _tabController;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _pageController = TextEditingController(text: '1');
|
||||
final TextEditingController _pageController =
|
||||
TextEditingController(text: '1');
|
||||
|
||||
List<Map<String, dynamic>> _surahs = [];
|
||||
List<Map<String, dynamic>> _searchResults = [];
|
||||
List<Map<String, dynamic>> _tafsirItems = [];
|
||||
List<Map<String, dynamic>> _asbabItems = [];
|
||||
List<Map<String, dynamic>> _juzItems = [];
|
||||
List<Map<String, dynamic>> _pageItems = [];
|
||||
List<Map<String, dynamic>> _themeItems = [];
|
||||
@@ -31,7 +39,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
bool _loadingInit = true;
|
||||
bool _loadingSearch = false;
|
||||
bool _loadingTafsir = false;
|
||||
bool _loadingAsbab = false;
|
||||
bool _loadingPage = false;
|
||||
String? _error;
|
||||
|
||||
@@ -42,7 +49,7 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 7, vsync: this);
|
||||
_tabController = TabController(length: 6, vsync: this);
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
@@ -69,9 +76,8 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_surahs = surahs;
|
||||
_selectedSurahId = surahs.isNotEmpty
|
||||
? ((surahs.first['nomor'] as int?) ?? 1)
|
||||
: 1;
|
||||
_selectedSurahId =
|
||||
surahs.isNotEmpty ? ((surahs.first['nomor'] as int?) ?? 1) : 1;
|
||||
_juzItems = juz;
|
||||
_themeItems = themes;
|
||||
_asmaItems = asma;
|
||||
@@ -79,7 +85,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
});
|
||||
|
||||
await _loadTafsirForSelectedSurah();
|
||||
await _loadAsbabForSelectedSurah();
|
||||
await _loadPageAyah();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
@@ -117,16 +122,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAsbabForSelectedSurah() async {
|
||||
setState(() => _loadingAsbab = true);
|
||||
final result = await MuslimApiService.instance.getAsbabBySurah(_selectedSurahId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_asbabItems = result;
|
||||
_loadingAsbab = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadPageAyah() async {
|
||||
setState(() => _loadingPage = true);
|
||||
final page = int.tryParse(_pageController.text.trim()) ?? _selectedPage;
|
||||
@@ -181,6 +176,62 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
return 'Surah $surahId';
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -188,12 +239,18 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Quran Enrichment'),
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _bootstrap,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Muat ulang',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showArabicFontSettings,
|
||||
icon: const Icon(LucideIcons.settings2),
|
||||
tooltip: 'Pengaturan tampilan',
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
@@ -206,7 +263,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
tabs: const [
|
||||
Tab(text: 'Cari'),
|
||||
Tab(text: 'Tafsir'),
|
||||
Tab(text: 'Asbab'),
|
||||
Tab(text: 'Juz'),
|
||||
Tab(text: 'Halaman'),
|
||||
Tab(text: 'Tema'),
|
||||
@@ -214,31 +270,34 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _loadingInit
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: _loadingInit
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchTab(context, isDark),
|
||||
_buildTafsirTab(context, isDark),
|
||||
_buildJuzTab(context, isDark),
|
||||
_buildPageTab(context, isDark),
|
||||
_buildThemeTab(context, isDark),
|
||||
_buildAsmaTab(context, isDark),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchTab(context, isDark),
|
||||
_buildTafsirTab(context, isDark),
|
||||
_buildAsbabTab(context, isDark),
|
||||
_buildJuzTab(context, isDark),
|
||||
_buildPageTab(context, isDark),
|
||||
_buildThemeTab(context, isDark),
|
||||
_buildAsmaTab(context, isDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,15 +401,12 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
child: ArabicText(
|
||||
ayah['arab']?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
baseFontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -396,13 +452,10 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ArabicText(
|
||||
word['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
baseFontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -474,41 +527,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAsbabTab(BuildContext context, bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSurahSelector(
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedSurahId = value);
|
||||
_loadAsbabForSelectedSurah();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: _loadingAsbab
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _asbabItems.isEmpty
|
||||
? _emptyText(
|
||||
isDark,
|
||||
'Belum ada data asbabun nuzul untuk surah ini',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
itemCount: _asbabItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _asbabItems[index];
|
||||
final ayah = item['nomorAyat']?.toString() ?? '-';
|
||||
return _buildCard(
|
||||
isDark,
|
||||
title: 'Ayat $ayah',
|
||||
body: item['text']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJuzTab(BuildContext context, bool isDark) {
|
||||
if (_juzItems.isEmpty) {
|
||||
return _emptyText(isDark, 'Data juz tidak tersedia');
|
||||
@@ -575,11 +593,11 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
final surahId = (item['surah'] as num?)?.toInt() ?? 0;
|
||||
final ayah = item['ayah']?.toString() ?? '-';
|
||||
|
||||
return _buildCard(
|
||||
return _buildArabicCard(
|
||||
isDark,
|
||||
title: '${_surahNameById(surahId)} : $ayah',
|
||||
body:
|
||||
'${item['arab']?.toString() ?? ''}\n\n${item['text']?.toString() ?? ''}',
|
||||
arabic: item['arab']?.toString() ?? '',
|
||||
translation: item['text']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -652,13 +670,10 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ArabicText(
|
||||
item['arab']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
Text(
|
||||
item['latin']?.toString() ?? '',
|
||||
@@ -727,7 +742,54 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(bool isDark, {required String title, required String body}) {
|
||||
Widget _buildArabicCard(
|
||||
bool isDark, {
|
||||
required String title,
|
||||
required String arabic,
|
||||
required String translation,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ArabicText(
|
||||
arabic,
|
||||
textAlign: TextAlign.right,
|
||||
baseFontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(translation, style: const TextStyle(height: 1.5)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(bool isDark,
|
||||
{required String title, required String body}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:hive_flutter/hive_flutter.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/local/models/quran_bookmark.dart';
|
||||
@@ -47,13 +48,16 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
void _showDisplaySettings() {
|
||||
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: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -134,140 +138,148 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: !widget.isSimpleModeTab,
|
||||
child: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari surah...',
|
||||
prefixIcon: Icon(LucideIcons.search,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari surah...',
|
||||
prefixIcon: Icon(LucideIcons.search,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Surah list
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filtered.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Tidak dapat memuat data'
|
||||
: 'Surah tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ValueListenableBuilder(
|
||||
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||
builder: (context, box, _) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
// Surah list
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filtered.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Tidak dapat memuat data'
|
||||
: 'Surah tidak ditemukan',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final surah = filtered[i];
|
||||
final number = surah['nomor'] ?? (i + 1);
|
||||
final nameLatin = surah['namaLatin'] ?? '';
|
||||
final nameArabic = surah['nama'] ?? '';
|
||||
final totalVerses = surah['jumlahAyat'] ?? 0;
|
||||
final tempatTurun = surah['tempatTurun'] ?? '';
|
||||
final arti = surah['arti'] ?? '';
|
||||
),
|
||||
)
|
||||
: ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<QuranBookmark>(HiveBoxes.bookmarks)
|
||||
.listenable(),
|
||||
builder: (context, box, _) {
|
||||
return ListView.separated(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final surah = filtered[i];
|
||||
final number = surah['nomor'] ?? (i + 1);
|
||||
final nameLatin = surah['namaLatin'] ?? '';
|
||||
final nameArabic = surah['nama'] ?? '';
|
||||
final totalVerses = surah['jumlahAyat'] ?? 0;
|
||||
final tempatTurun = surah['tempatTurun'] ?? '';
|
||||
final arti = surah['arti'] ?? '';
|
||||
|
||||
final hasLastRead = box.values.any((b) => b.isLastRead && b.surahId == number);
|
||||
final hasLastRead = box.values.any(
|
||||
(b) => b.isLastRead && b.surahId == number);
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.push(widget.isSimpleModeTab
|
||||
? '/quran/$number'
|
||||
: '/tools/quran/$number'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 0, vertical: 6),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
return ListTile(
|
||||
onTap: () => context.push(
|
||||
widget.isSimpleModeTab
|
||||
? '/quran/$number'
|
||||
: '/tools/quran/$number'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 0, vertical: 6),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
nameLatin,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
nameLatin,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasLastRead) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(LucideIcons.pin, size: 14, color: AppColors.primary),
|
||||
if (hasLastRead) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(LucideIcons.pin,
|
||||
size: 14, color: AppColors.primary),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'$arti • $totalVerses Ayat • $tempatTurun',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
nameArabic,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 18,
|
||||
subtitle: Text(
|
||||
'$arti • $totalVerses Ayat • $tempatTurun',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
trailing: ArabicText(
|
||||
nameArabic,
|
||||
baseFontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../../../app/icons/app_icons.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/ayat_today_card.dart';
|
||||
import '../../../core/widgets/notification_bell_button.dart';
|
||||
import '../../../core/widgets/tool_card.dart';
|
||||
import '../../../data/services/muslim_api_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
class ToolsScreen extends ConsumerWidget {
|
||||
const ToolsScreen({super.key});
|
||||
@@ -12,19 +17,72 @@ class ToolsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSimpleMode =
|
||||
Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ??
|
||||
false;
|
||||
final cards = <Widget>[
|
||||
if (!isSimpleMode)
|
||||
ToolCard(
|
||||
icon: AppIcons.quran,
|
||||
title: "Al-Qur'an\nTerjemahan",
|
||||
color: const Color(0xFF00B894),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.murattal,
|
||||
title: "Qur'an\nMurattal",
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.qibla,
|
||||
title: 'Arah\nKiblat',
|
||||
color: const Color(0xFF0984E3),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
),
|
||||
if (!isSimpleMode)
|
||||
ToolCard(
|
||||
icon: AppIcons.dzikir,
|
||||
title: 'Dzikir\nHarian',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.doa,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/doa'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.hadits,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/hadits'),
|
||||
),
|
||||
ToolCard(
|
||||
icon: AppIcons.quranEnrichment,
|
||||
title: "Pendalaman\nAl-Qur'an",
|
||||
color: const Color(0xFF00CEC9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/enrichment'),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Alat Islami'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(LucideIcons.bell),
|
||||
),
|
||||
const NotificationBellButton(),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
icon: const AppIcon(glyph: AppIcons.settings),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -34,7 +92,7 @@ class ToolsScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'AKSES CEPAT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -44,193 +102,37 @@ class ToolsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.bookOpen,
|
||||
title: 'Al-Quran\nTerjemahan',
|
||||
color: const Color(0xFF00B894),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.headphones,
|
||||
title: 'Quran\nMurattal',
|
||||
color: const Color(0xFF7B61FF),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/1/murattal'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.compass,
|
||||
title: 'Arah\nKiblat',
|
||||
color: const Color(0xFF0984E3),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Dzikir\nHarian',
|
||||
color: AppColors.primary,
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/dzikir'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.heart,
|
||||
title: 'Kumpulan\nDoa',
|
||||
color: const Color(0xFFE17055),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/doa'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.library,
|
||||
title: "Hadits\nArba'in",
|
||||
color: const Color(0xFF6C5CE7),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/hadits'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ToolCard(
|
||||
icon: LucideIcons.sparkles,
|
||||
title: 'Quran\nEnrichment',
|
||||
color: const Color(0xFF00CEC9),
|
||||
isDark: isDark,
|
||||
onTap: () => context.push('/tools/quran/enrichment'),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
_buildQuickActionsGrid(cards),
|
||||
const SizedBox(height: 28),
|
||||
FutureBuilder<Map<String, dynamic>?>(
|
||||
future: MuslimApiService.instance.getDailyAyat(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final data = snapshot.data!;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Ayat Hari Ini',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.share2,
|
||||
size: 18,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
data['teksArab'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
AyatTodayCard(
|
||||
headerText: 'Ayat Hari Ini',
|
||||
headerStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionsGrid(List<Widget> cards) {
|
||||
const spacing = 12.0;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardWidth = (constraints.maxWidth - spacing) / 2;
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final card in cards) SizedBox(width: cardWidth, child: card),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:just_audio_background/just_audio_background.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'data/local/hive_boxes.dart';
|
||||
import 'data/local/models/app_settings.dart';
|
||||
import 'data/services/notification_inbox_service.dart';
|
||||
import 'data/services/notification_orchestrator_service.dart';
|
||||
import 'data/services/remote_push_service.dart';
|
||||
import 'data/services/notification_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -17,6 +27,31 @@ void main() async {
|
||||
// Seed default settings and checklist items on first launch
|
||||
await seedDefaults();
|
||||
|
||||
// Ensure intl DateFormat locale data is ready before any localized formatting.
|
||||
await initializeDateFormatting('id_ID');
|
||||
|
||||
// Initialize local notifications for adzan/iqamah scheduling
|
||||
await NotificationService.instance.init();
|
||||
await RemotePushService.instance.init();
|
||||
|
||||
// Run passive notification checks at startup (inbox cleanup/content sync).
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default') ?? AppSettings();
|
||||
// Cleanup legacy mirrored prayer inbox items.
|
||||
await NotificationInboxService.instance.removeByType('prayer');
|
||||
unawaited(NotificationService.instance.syncHabitNotifications(
|
||||
settings: settings,
|
||||
));
|
||||
unawaited(NotificationOrchestratorService.instance.runPassivePass(
|
||||
settings: settings,
|
||||
));
|
||||
|
||||
await JustAudioBackground.init(
|
||||
androidNotificationChannelId: 'com.jamshalat.diary.audio',
|
||||
androidNotificationChannelName: 'Murattal Playback',
|
||||
androidNotificationOngoing: true,
|
||||
);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: App(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import flutter_local_notifications
|
||||
import geolocator_apple
|
||||
import just_audio
|
||||
import package_info_plus
|
||||
import share_plus
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
@@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
80
pubspec.lock
80
pubspec.lock
@@ -249,6 +249,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -411,6 +419,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0-dev.3"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -629,6 +645,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
hugeicons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hugeicons
|
||||
sha256: d19c0e2b57ccf455dd8ef08b84da40ae6dbba898c92960a0a0ada77df7865b8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -677,6 +701,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.5"
|
||||
just_audio_background:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio_background
|
||||
sha256: "3900825701164577db65337792bc122b66f9eb1245ee47e7ae8244ae5ceb8030"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.1-beta.17"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -829,6 +861,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -965,6 +1005,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.27.7"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1210,6 +1266,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.20"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
20
pubspec.yaml
20
pubspec.yaml
@@ -48,8 +48,11 @@ dependencies:
|
||||
http: ^1.2.0
|
||||
flutter_dotenv: ^5.1.0
|
||||
cached_network_image: ^3.3.1
|
||||
share_plus: ^10.1.4
|
||||
url_launcher: ^6.2.5
|
||||
lucide_icons: ^0.257.0
|
||||
hugeicons: ^1.1.5
|
||||
just_audio_background: ^0.0.1-beta.17
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -84,6 +87,19 @@ flutter:
|
||||
- family: Amiri
|
||||
fonts:
|
||||
- asset: assets/fonts/Amiri-Regular.ttf
|
||||
- asset: assets/fonts/Amiri-Bold.ttf
|
||||
# Temporary: use Regular for 700 until real Amiri-Bold.ttf is added.
|
||||
# Current Amiri-Bold.ttf in repo is a placeholder text file.
|
||||
- asset: assets/fonts/Amiri-Regular.ttf
|
||||
weight: 700
|
||||
- family: KFGQPCUthmanicHafs
|
||||
fonts:
|
||||
- asset: assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
|
||||
- family: UthmanTahaNaskh
|
||||
fonts:
|
||||
- asset: assets/fonts/UthmanTN1-Ver10.otf
|
||||
- family: ScheherazadeNew
|
||||
fonts:
|
||||
- asset: assets/fonts/ScheherazadeNew-Regular.ttf
|
||||
weight: 400
|
||||
- asset: assets/fonts/ScheherazadeNew-Bold.ttf
|
||||
weight: 700
|
||||
|
||||
|
||||
Reference in New Issue
Block a user