Compare commits
2 Commits
c4696f2d9f
...
2d09b5b356
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d09b5b356 | ||
|
|
a049129a35 |
@@ -18,6 +18,12 @@ migration:
|
|||||||
- platform: android
|
- platform: android
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
- platform: ios
|
||||||
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
- platform: macos
|
||||||
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ val keystoreProperties = Properties()
|
|||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
val hasReleaseKeystore = listOf(
|
||||||
|
"keyAlias",
|
||||||
|
"keyPassword",
|
||||||
|
"storeFile",
|
||||||
|
"storePassword",
|
||||||
|
).all { key ->
|
||||||
|
val value = keystoreProperties[key] as String?
|
||||||
|
!value.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.jamshalat.diary"
|
namespace = "com.jamshalat.diary"
|
||||||
@@ -30,6 +39,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
@@ -38,6 +48,7 @@ android {
|
|||||||
storeType = "PKCS12"
|
storeType = "PKCS12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
@@ -52,9 +63,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Use release keystore if configured, otherwise fallback to debug
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// signing so local release APK builds remain possible.
|
||||||
signingConfig = signingConfigs.getByName("release")
|
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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_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
|
<application
|
||||||
android:label="Jamshalat Diary"
|
android:label="Jamshalat Diary"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -33,6 +38,21 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", 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 {
|
try {
|
||||||
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
|
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
package com.jamshalat.diary
|
package com.jamshalat.diary
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import android.hardware.GeomagneticField
|
||||||
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity : AudioServiceActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val GEOMAGNETIC_CHANNEL = "com.jamshalat.diary/geomagnetic"
|
||||||
|
private const val DECLINATION_METHOD = "getDeclination"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, GEOMAGNETIC_CHANNEL)
|
||||||
|
.setMethodCallHandler(::handleGeomagneticMethodCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGeomagneticMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (call.method != DECLINATION_METHOD) {
|
||||||
|
result.notImplemented()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val latitude = call.argument<Double>("latitude")
|
||||||
|
val longitude = call.argument<Double>("longitude")
|
||||||
|
if (latitude == null || longitude == null) {
|
||||||
|
result.error("INVALID_ARGS", "Latitude and longitude are required.", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val altitude = call.argument<Double>("altitude") ?: 0.0
|
||||||
|
val timestamp = call.argument<Long>("timestamp") ?: System.currentTimeMillis()
|
||||||
|
val field = GeomagneticField(
|
||||||
|
latitude.toFloat(),
|
||||||
|
longitude.toFloat(),
|
||||||
|
altitude.toFloat(),
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
result.success(field.declination.toDouble())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.jamshalat.jamshalat_diary
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -1,5 +1,5 @@
|
|||||||
sdk.dir=/Users/dwindown/Library/Android/sdk
|
sdk.dir=/Users/dwindown/Library/Android/sdk
|
||||||
flutter.sdk=/Users/dwindown/FlutterDev/flutter
|
flutter.sdk=/opt/homebrew/share/flutter
|
||||||
flutter.buildMode=release
|
flutter.buildMode=release
|
||||||
flutter.versionName=1.0.0
|
flutter.versionName=1.0.0
|
||||||
flutter.versionCode=1
|
flutter.versionCode=1
|
||||||
BIN
assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
Normal file
BIN
assets/fonts/ScheherazadeNew-Bold.ttf
Normal file
BIN
assets/fonts/ScheherazadeNew-Regular.ttf
Normal file
BIN
assets/fonts/UthmanTN1-Ver10.otf
Normal file
BIN
assets/images/blob.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/images/logo_normal.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/images/logo_white.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
248
docs/notification-plan.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Notification Plan (Alerts + Inbox)
|
||||||
|
|
||||||
|
Last updated: 2026-03-16
|
||||||
|
Owner: Product + Mobile
|
||||||
|
Scope: `jamshalat_diary` (Flutter)
|
||||||
|
|
||||||
|
## Implementation Status (2026-03-17)
|
||||||
|
- Phase 1: Implemented.
|
||||||
|
- Phase 2: Implemented (Notification Center + unread badge + inbox persistence).
|
||||||
|
- Phase 3: Implemented (daily checklist reminder scheduling, streak-risk messages, weekly summary, quiet-hours and daily cap preferences).
|
||||||
|
- Phase 4: Partially implemented via remote-content sync and remote-push ingestion bridge.
|
||||||
|
- External pending for full Phase 4: SDK/provider wiring (FCM/APNs credentials, token registration, backend push dispatch).
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
Current app has mixed meaning for "notification":
|
||||||
|
- Device push alert (time-sensitive, appears in system drawer)
|
||||||
|
- In-app message feed (read-later information)
|
||||||
|
|
||||||
|
This causes unclear UX around the bell icon and settings.
|
||||||
|
|
||||||
|
## 2. Product Model
|
||||||
|
Split into two clear products:
|
||||||
|
|
||||||
|
1. **Alerts** (`Pemberitahuan`)
|
||||||
|
- Time-sensitive pushes to OS notification drawer.
|
||||||
|
- Can ring/vibrate.
|
||||||
|
- Expire quickly.
|
||||||
|
|
||||||
|
2. **Inbox** (`Pesan`)
|
||||||
|
- In-app list of messages user can read later.
|
||||||
|
- Has read/unread state.
|
||||||
|
- No mandatory sound.
|
||||||
|
|
||||||
|
Default rule:
|
||||||
|
- **Push only**: urgent + expiring event.
|
||||||
|
- **Inbox only**: informational/non-urgent event.
|
||||||
|
- **Push + Inbox**: important event that also needs follow-up context.
|
||||||
|
|
||||||
|
## 3. Terminology (User-Facing)
|
||||||
|
- Bell icon label/context: `Pemberitahuan`
|
||||||
|
- Notification center tabs:
|
||||||
|
- `Alarm` (alert history + system-triggered items)
|
||||||
|
- `Pesan` (inbox/read-later)
|
||||||
|
|
||||||
|
## 4. Event Policy Matrix
|
||||||
|
| Event | Push Drawer | Inbox | Sound | Expiry |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Adzan time entered | Yes | Optional (off by default) | Yes | At end of prayer window |
|
||||||
|
| Iqamah reminder | Yes | No | Yes | At iqamah time |
|
||||||
|
| Next prayer in 10 minutes | Optional | No | Soft | At prayer start |
|
||||||
|
| Daily checklist reminder | Yes (if enabled) | No | Optional | End of day |
|
||||||
|
| Streak at risk (Tilawah/Dzikir) | Optional | Yes | No | End of day |
|
||||||
|
| Weekly worship summary | No | Yes | No | 7 days |
|
||||||
|
| Permission blocked (notif/exact alarm/location) | Yes | Yes | No | When resolved |
|
||||||
|
| Schedule/location stale | Yes | Yes | No | When resolved |
|
||||||
|
| New app content (Doa/Hadits) | No | Yes | No | 30 days |
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
## 5.1 Channels
|
||||||
|
- **Local scheduled alerts** (already started in app): Adzan + Iqamah.
|
||||||
|
- **In-app inbox storage** (new): persisted read/unread messages.
|
||||||
|
- **Remote push** (future): FCM/APNs for server-driven campaigns/events.
|
||||||
|
|
||||||
|
## 5.2 Data Sources
|
||||||
|
- Prayer schedules: `MyQuranSholatService`.
|
||||||
|
- User prefs: `AppSettings` + new notification preference model.
|
||||||
|
- Device status: notification permission, exact alarm permission, location service.
|
||||||
|
|
||||||
|
## 5.3 Decision Engine
|
||||||
|
Input:
|
||||||
|
- event type
|
||||||
|
- urgency
|
||||||
|
- TTL
|
||||||
|
- user preferences
|
||||||
|
- cooldown/frequency cap
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- deliver to `push`, `inbox`, or both.
|
||||||
|
|
||||||
|
Pseudo rule:
|
||||||
|
```text
|
||||||
|
if event.isTimeCritical && event.ttlShort:
|
||||||
|
push
|
||||||
|
if event.needsFollowUp || event.referenceContent:
|
||||||
|
inbox
|
||||||
|
if event.push && event.inboxPolicy == mirror:
|
||||||
|
push + inbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Data Model
|
||||||
|
## 6.1 Inbox Item (Hive)
|
||||||
|
Suggested new box: `notification_inbox`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"type": "streak_risk | summary | system | content | prayer",
|
||||||
|
"title": "string",
|
||||||
|
"body": "string",
|
||||||
|
"createdAt": "iso8601",
|
||||||
|
"expiresAt": "iso8601|null",
|
||||||
|
"readAt": "iso8601|null",
|
||||||
|
"isPinned": false,
|
||||||
|
"deeplink": "/route/path",
|
||||||
|
"meta": {
|
||||||
|
"cityId": "string|null",
|
||||||
|
"prayerKey": "fajr|dhuhr|asr|maghrib|isha|null",
|
||||||
|
"date": "yyyy-MM-dd|null"
|
||||||
|
},
|
||||||
|
"source": "local|remote",
|
||||||
|
"dedupeKey": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6.2 Preferences
|
||||||
|
Use existing `AppSettings` for:
|
||||||
|
- `adhanEnabled` (per prayer)
|
||||||
|
- `iqamahOffset` (per prayer)
|
||||||
|
|
||||||
|
Add new fields (either in `AppSettings` or separate box):
|
||||||
|
- `alertsEnabled` (global)
|
||||||
|
- `inboxEnabled` (global)
|
||||||
|
- `streakRiskEnabled`
|
||||||
|
- `dailyChecklistReminderEnabled`
|
||||||
|
- `weeklySummaryEnabled`
|
||||||
|
- `quietHoursStart` / `quietHoursEnd`
|
||||||
|
- `maxNonPrayerPushPerDay` (default 2)
|
||||||
|
|
||||||
|
## 6.3 Badge Source
|
||||||
|
- Bell badge count = unread inbox count only.
|
||||||
|
- Do not include "already fired system push" count.
|
||||||
|
|
||||||
|
## 7. Local Notification Spec (Current + Next)
|
||||||
|
## 7.1 Channels
|
||||||
|
- `adhan_channel`: max importance, sound on.
|
||||||
|
- `iqamah_channel`: high importance, sound on.
|
||||||
|
- Later:
|
||||||
|
- `habit_channel` (streak/checklist)
|
||||||
|
- `system_channel` (permission/location)
|
||||||
|
|
||||||
|
## 7.2 Permission Strategy
|
||||||
|
- Request `POST_NOTIFICATIONS` and exact alarm capability only when needed.
|
||||||
|
- If denied:
|
||||||
|
- create Inbox warning item
|
||||||
|
- show non-blocking UI notice
|
||||||
|
|
||||||
|
## 7.3 Scheduling Rules
|
||||||
|
- Keep 2-day rolling window (today + tomorrow).
|
||||||
|
- Resync on:
|
||||||
|
- app start
|
||||||
|
- city change
|
||||||
|
- prayer settings change
|
||||||
|
- adzan toggle change
|
||||||
|
- daily boundary change (00:05 local)
|
||||||
|
- Deduplicate by key: `cityId + date + prayer + kind(adhan/iqamah)`.
|
||||||
|
|
||||||
|
## 8. UX Specification
|
||||||
|
## 8.1 Bell Icon
|
||||||
|
- Tap action: open Notification Center.
|
||||||
|
- Badge: unread inbox count.
|
||||||
|
- Long-press (optional): quick actions
|
||||||
|
- toggle `Alarm Sholat`
|
||||||
|
- open Iqamah settings
|
||||||
|
- sync now
|
||||||
|
|
||||||
|
## 8.2 Notification Center Screen
|
||||||
|
Tabs:
|
||||||
|
- `Alarm`
|
||||||
|
- recent fired alerts (read-only timeline, optional v2)
|
||||||
|
- `Pesan`
|
||||||
|
- unread/read list with filters (`Semua`, `Belum Dibaca`, `Sistem`)
|
||||||
|
|
||||||
|
Item actions:
|
||||||
|
- Tap: open deeplink target.
|
||||||
|
- Swipe: mark read/unread.
|
||||||
|
- Optional: pin important item.
|
||||||
|
|
||||||
|
## 8.3 Copy Guidelines
|
||||||
|
- Urgent: concise, action-first.
|
||||||
|
- Inbox: context-rich but short (max ~120 chars body preview).
|
||||||
|
- Prayer push examples:
|
||||||
|
- `Adzan • Subuh`
|
||||||
|
- `Waktu sholat Subuh telah masuk.`
|
||||||
|
|
||||||
|
## 9. Deep Link Map
|
||||||
|
- Permission blocked -> `/settings` (notifications section)
|
||||||
|
- Location disabled -> `/settings` or location setup section
|
||||||
|
- Streak risk Tilawah -> `/quran`
|
||||||
|
- Streak risk Dzikir -> `/tools/dzikir`
|
||||||
|
- Weekly summary -> `/laporan`
|
||||||
|
- Prayer event details -> `/` (Beranda)
|
||||||
|
|
||||||
|
## 10. Analytics
|
||||||
|
Track:
|
||||||
|
- `notif_push_scheduled`
|
||||||
|
- `notif_push_fired`
|
||||||
|
- `notif_push_opened`
|
||||||
|
- `notif_inbox_created`
|
||||||
|
- `notif_inbox_opened`
|
||||||
|
- `notif_mark_read`
|
||||||
|
- `notif_settings_changed`
|
||||||
|
- `notif_permission_denied`
|
||||||
|
|
||||||
|
Dimensions:
|
||||||
|
- `event_type`
|
||||||
|
- `channel` (`push|inbox|both`)
|
||||||
|
- `city_id`
|
||||||
|
- `simple_mode`
|
||||||
|
|
||||||
|
## 11. Rollout Plan
|
||||||
|
## Phase 1 (Now)
|
||||||
|
- Stabilize prayer alerts (Adzan + Iqamah) local scheduling.
|
||||||
|
- Working sound toggle in hero card and settings.
|
||||||
|
- Permission and exact-alarm checks.
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
- Build Notification Center page + unread badge.
|
||||||
|
- Add inbox persistence and read/unread actions.
|
||||||
|
- Add system warning messages to inbox.
|
||||||
|
|
||||||
|
## Phase 3
|
||||||
|
- Add non-prayer reminders (checklist, streak risk, weekly summary).
|
||||||
|
- Add cooldown/frequency cap and quiet hours.
|
||||||
|
|
||||||
|
## Phase 4
|
||||||
|
- Remote push integration (FCM/APNs).
|
||||||
|
- Server-defined campaigns/content updates.
|
||||||
|
|
||||||
|
## 12. Acceptance Criteria
|
||||||
|
- Toggling adzan off removes all pending adzan/iqamah notifications.
|
||||||
|
- Toggling adzan on schedules valid upcoming notifications for enabled prayers.
|
||||||
|
- Changing iqamah minutes updates future scheduled iqamah alerts immediately.
|
||||||
|
- Bell opens Notification Center (after Phase 2).
|
||||||
|
- Unread badge count reflects inbox unread only.
|
||||||
|
- No duplicate notifications for same prayer/time/city.
|
||||||
|
|
||||||
|
## 13. QA Checklist
|
||||||
|
- Android 13/14: permission denied -> graceful fallback.
|
||||||
|
- Exact alarm disabled -> user warning path works.
|
||||||
|
- Timezone changes -> schedule recalculates correctly.
|
||||||
|
- Day rollover -> next-day notifications still present.
|
||||||
|
- City changes -> old city schedules removed; new city schedules added.
|
||||||
|
- Device reboot (future): optional reschedule receiver if needed.
|
||||||
|
|
||||||
|
## 14. Open Decisions
|
||||||
|
1. Should adzan events be mirrored into inbox by default?
|
||||||
|
2. Should quiet hours suppress non-prayer push only, or all push except adzan?
|
||||||
|
3. Should `Alarm` tab show only fired events or also pending schedule preview?
|
||||||
81
dzikir-display-mode-ux-brief.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Dzikir Display Mode UX Brief
|
||||||
|
|
||||||
|
## 1) Objective
|
||||||
|
Provide two complementary experiences for Dzikir:
|
||||||
|
|
||||||
|
- **Daftar (Baris)** for fast scanning and jumping between items.
|
||||||
|
- **Fokus (Slide)** for one-item focus with consistent thumb reach and counting flow.
|
||||||
|
|
||||||
|
This mode applies to all Dzikir tabs: **Pagi**, **Petang**, and **Sesudah Shalat**.
|
||||||
|
|
||||||
|
## 2) Settings Specification
|
||||||
|
|
||||||
|
Section name in Settings: **Tampilan Dzikir**
|
||||||
|
|
||||||
|
| Label | Type | Options | Default | Visibility |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Mode Tampilan Dzikir` | Segmented | `Daftar (Baris)` / `Fokus (Slide)` | `Daftar (Baris)` | Always |
|
||||||
|
| `Posisi Tombol Hitung` | Segmented | `Pill Bawah (Disarankan)` / `Bulat Kanan Bawah` | `Pill Bawah (Disarankan)` | Only in `Fokus (Slide)` |
|
||||||
|
| `Lanjut Otomatis Saat Target Tercapai` | Switch | `On/Off` | `On` | Only in `Fokus (Slide)` |
|
||||||
|
| `Getaran Saat Hitung` | Switch | `On/Off` | `On` | Always |
|
||||||
|
|
||||||
|
## 3) Interaction Rules
|
||||||
|
|
||||||
|
### A. Mode: Daftar (Baris)
|
||||||
|
- Keep current row-based list and per-row counter pattern.
|
||||||
|
- Users can scan, jump, and increment any row directly.
|
||||||
|
- Counter behavior remains per item, per day.
|
||||||
|
|
||||||
|
### B. Mode: Fokus (Slide)
|
||||||
|
- Display exactly **one dzikir item per slide**.
|
||||||
|
- Horizontal swipe moves between dzikir items.
|
||||||
|
- Counter button is fixed in one location (based on selected button position).
|
||||||
|
- Top area displays progress: `Item X dari Y`.
|
||||||
|
- Tapping counter increments by `+1` until target.
|
||||||
|
- When target reached:
|
||||||
|
- Mark item as complete.
|
||||||
|
- If `Lanjut Otomatis... = On`, move to next slide automatically (except last item).
|
||||||
|
|
||||||
|
## 4) Button Placement Recommendation
|
||||||
|
|
||||||
|
Primary recommendation:
|
||||||
|
|
||||||
|
- **Pill Bawah (Disarankan)** as default in Focus mode.
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Better one-handed ergonomics.
|
||||||
|
- Consistent location improves counting rhythm.
|
||||||
|
- Larger tap target lowers miss taps while reciting.
|
||||||
|
|
||||||
|
Optional style:
|
||||||
|
- **Bulat Kanan Bawah** for users preferring minimal visual footprint.
|
||||||
|
|
||||||
|
## 5) Data & State Behavior
|
||||||
|
|
||||||
|
- Counter data is shared across modes (switching mode must not reset progress).
|
||||||
|
- Existing daily tracking logic remains unchanged.
|
||||||
|
- Switching mode keeps current tab (`Pagi/Petang/Sesudah Shalat`) intact.
|
||||||
|
- Completed state must be reflected identically in both modes.
|
||||||
|
|
||||||
|
## 6) Completion & Feedback UX
|
||||||
|
|
||||||
|
- Counter states: `normal` and `completed`.
|
||||||
|
- Completed label example: `Selesai`.
|
||||||
|
- Last item completion feedback:
|
||||||
|
- Show subtle confirmation message: `Semua dzikir pada tab ini selesai`.
|
||||||
|
- Empty or missing data:
|
||||||
|
- Show friendly empty state, never blank screen.
|
||||||
|
|
||||||
|
## 7) Default Product Decision
|
||||||
|
|
||||||
|
- App default: **Daftar (Baris)** for broad familiarity.
|
||||||
|
- Advanced/focus users can enable **Fokus (Slide)**.
|
||||||
|
- In Focus mode, default button placement: **Pill Bawah (Disarankan)**.
|
||||||
|
|
||||||
|
## 8) Success Criteria
|
||||||
|
|
||||||
|
- Users can switch between modes without losing count progress.
|
||||||
|
- Focus mode reduces hand travel for repeated taps.
|
||||||
|
- Both modes remain consistent across all Dzikir tabs.
|
||||||
|
- No behavioral mismatch between count target, completion state, and progress indicator.
|
||||||
|
|
||||||
173
hugeicons-migration-spec.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# HugeIcons Migration Spec
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Replace current Lucide icon usage with HugeIcons targets.
|
||||||
|
- Prioritize Islamic-context icons first (bottom nav, Beranda, Lainnya, Qibla, Qur'an entry points).
|
||||||
|
- Keep UI behavior unchanged; this spec is icon-only.
|
||||||
|
|
||||||
|
## Inventory Summary (Current State)
|
||||||
|
- Total Lucide references: `158`
|
||||||
|
- Unique Lucide symbols: `72`
|
||||||
|
- Non-Lucid icon reference: `Icons.arrow_back` in `quran_murattal_screen.dart`
|
||||||
|
|
||||||
|
Top icon-heavy files:
|
||||||
|
1. `lib/features/settings/presentation/settings_screen.dart`
|
||||||
|
2. `lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||||
|
3. `lib/features/quran/presentation/quran_reading_screen.dart`
|
||||||
|
4. `lib/features/checklist/presentation/checklist_screen.dart`
|
||||||
|
5. `lib/features/tools/presentation/tools_screen.dart`
|
||||||
|
|
||||||
|
## Recommended HugeIcons Style Policy
|
||||||
|
- `Stroke Rounded` for navigation/system icons.
|
||||||
|
- `Duotone Rounded` for high-emphasis feature cards.
|
||||||
|
- Islamic-specific icons use explicit semantic slugs (Qur'an, Tasbih, Kaaba, Dua, Mosque).
|
||||||
|
|
||||||
|
## Canonical Mapping (Lucide -> HugeIcons Candidate)
|
||||||
|
Notes:
|
||||||
|
- `Confidence` = how certain the target slug/semantic fit is.
|
||||||
|
- `High` means already validated semantically on HugeIcons.
|
||||||
|
- `Medium` means strong candidate but should be quickly verified in final implementation pass.
|
||||||
|
|
||||||
|
| Lucide | HugeIcons candidate | Confidence | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `home` | `home-01-stroke-rounded` | High | Bottom nav Beranda |
|
||||||
|
| `calendar` | `calendar-01` | High | Bottom nav Jadwal |
|
||||||
|
| `bookOpen` | `quran-02-solid-sharp` (feature), `book-open-01-stroke-rounded` (generic) | High | Use Qur'an variant where context is Qur'an |
|
||||||
|
| `sparkles` | `tasbih` (Dzikir), `sparkles`-family for generic | High/Medium | Dzikir should use Tasbih |
|
||||||
|
| `wand2` | `magic-wand-01-duotone-rounded` | High | Bottom nav Lainnya |
|
||||||
|
| `listChecks` | `check-list` | High | Ibadah list |
|
||||||
|
| `barChart3` | `bar-chart-03-stroke-rounded` | High | Laporan |
|
||||||
|
| `headphones` | `headset-solid-rounded` | Medium | Murattal/audio |
|
||||||
|
| `compass` | `compass-duotone-rounded` | High | Qibla entry/action |
|
||||||
|
| `library` | `books-01-solid-standard` | High | Hadits card |
|
||||||
|
| `heart` | `dua-solid-sharp` (Doa context), `heart`-family (generic) | High/Medium | Prefer Dua icon for Doa feature |
|
||||||
|
| `mapPin` | `mosque-location` (Qibla/location context), `location-01` (generic) | High/Medium | Prefer mosque-location in Islamic context |
|
||||||
|
| `locate` | `compass-duotone-rounded` or `location-focus`-family | Medium | Qibla live-state |
|
||||||
|
| `locateOff` | `location-off`-family | Medium | Qibla sensor-off |
|
||||||
|
| `bookmark` | `bookmark-02-stroke-rounded` | Medium | Qur'an/bookmarks |
|
||||||
|
| `pin` | `pin-location`-family | Medium | Last read |
|
||||||
|
| `search` | `search-01-stroke-rounded` | Medium | Global search |
|
||||||
|
| `refreshCw` | `refresh`-family | Medium | Reload actions |
|
||||||
|
| `settings` | `settings-02-stroke-rounded` | Medium | Global settings |
|
||||||
|
| `settings2` | `settings-02-stroke-rounded` | Medium | Keep one settings variant |
|
||||||
|
| `bell` | `notification`-family | Medium | Notification |
|
||||||
|
| `checkCircle2` | `checkmark-circle`-family | Medium | Completion status |
|
||||||
|
| `circle` | `circle`-family | Medium | Unchecked state |
|
||||||
|
| `check` | `tick`-family | Medium | Confirmed state |
|
||||||
|
| `chevronLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
|
||||||
|
| `chevronRight` | `arrow-right-01-stroke-rounded` | Medium | Settings rows |
|
||||||
|
| `chevronDown` | `arrow-down-01-stroke-rounded` | Medium | Dropdown |
|
||||||
|
| `chevronUp` | `arrow-up-01-stroke-rounded` | Medium | Expand/collapse |
|
||||||
|
| `arrowLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
|
||||||
|
| `arrowRight` | `arrow-right-01-stroke-rounded` | Medium | Forward |
|
||||||
|
| `clock` | `time`-family | Medium | Prayer/meta time |
|
||||||
|
| `sunrise` | `sunrise`-family | Medium | Prayer icon |
|
||||||
|
| `sun` | `sun`-family | Medium | Prayer icon |
|
||||||
|
| `cloudSun` | `cloud-sun`-family | Medium | Prayer icon |
|
||||||
|
| `sunset` | `sunset`-family | Medium | Prayer icon |
|
||||||
|
| `moon` | `moon`-family | Medium | Prayer/night |
|
||||||
|
| `volume2` | `volume-high`-family | Medium | Audio toggle |
|
||||||
|
| `user` | `user-03-stroke-rounded` | Medium | Profile/author |
|
||||||
|
| `quote` | `quote`-family | Medium | Ayat quote panel |
|
||||||
|
| `fingerprint` | `fingerprint-01` | Medium | Dzikir tap counter |
|
||||||
|
| `inbox` | `inbox-01` | Medium | Empty state |
|
||||||
|
| `wifiOff` | `wifi-off-01` | Medium | Offline state |
|
||||||
|
| `star` | `star`-family | Medium | Ratings/highlight |
|
||||||
|
| `building` | `mosque-01` (Islamic), `building`-family (generic) | Medium | Prefer mosque if religious place context |
|
||||||
|
| `minusCircle` | `minus-sign-circle`-family | Medium | Counter controls |
|
||||||
|
| `plusCircle` | `plus-sign-circle`-family | Medium | Counter controls |
|
||||||
|
| `moonStar` | `moon-stars`-family | Medium | Night checklist |
|
||||||
|
| `trendingUp` | `chart-up`-family | Medium | Trend KPI |
|
||||||
|
| `history` | `history`-family | Medium | Reports history |
|
||||||
|
| `share2` | `share`-family | Medium | Share action |
|
||||||
|
| `play` | `play`-family | Medium | Media |
|
||||||
|
| `pause` | `pause`-family | Medium | Media |
|
||||||
|
| `stopCircle` | `stop-circle`-family | Medium | Media/record stop |
|
||||||
|
| `playCircle` | `play-circle`-family | Medium | Media/record play |
|
||||||
|
| `square` | `stop-square`-family | Medium | Media |
|
||||||
|
| `skipBack` | `skip-back`-family | Medium | Media |
|
||||||
|
| `skipForward` | `skip-forward`-family | Medium | Media |
|
||||||
|
| `shuffle` | `shuffle`-family | Medium | Media |
|
||||||
|
| `listMusic` | `playlist`-family | Medium | Media list |
|
||||||
|
| `music` | `music-note`-family | Medium | Media indicator |
|
||||||
|
| `brain` | `brain`-family | Medium | Hafalan mode |
|
||||||
|
| `gem` | `diamond`/`gem`-family | Medium | Highlight badge |
|
||||||
|
| `flag` | `flag`-family | Medium | Marker |
|
||||||
|
| `trash2` | `delete`-family | Medium | Remove bookmark |
|
||||||
|
| `layoutDashboard` | `dashboard-square`-family | Medium | Settings |
|
||||||
|
| `timer` | `timer`-family | Medium | Timing settings |
|
||||||
|
| `type` | `text`-family | Medium | Typography settings |
|
||||||
|
| `vibrate` | `vibration`-family | Medium | Haptic settings |
|
||||||
|
| `pencil` | `edit`-family | Medium | Edit profile |
|
||||||
|
| `info` | `information-circle`-family | Medium | Info |
|
||||||
|
| `logOut` | `logout-01`-family | Medium | Logout |
|
||||||
|
| `languages` | `language`-family | Medium | Qur'an enrichment language |
|
||||||
|
|
||||||
|
## Screen-Level Migration Targets
|
||||||
|
|
||||||
|
### P0: Bottom Navigation
|
||||||
|
File: `lib/core/widgets/bottom_nav_bar.dart`
|
||||||
|
- `home` -> `home-01-stroke-rounded`
|
||||||
|
- `calendar` -> `calendar-01`
|
||||||
|
- `bookOpen` -> `quran-02-solid-sharp`
|
||||||
|
- `sparkles` -> `tasbih`
|
||||||
|
- `wand2` -> `magic-wand-01-duotone-rounded`
|
||||||
|
- `listChecks` -> `check-list`
|
||||||
|
- `barChart3` -> `bar-chart-03-stroke-rounded`
|
||||||
|
|
||||||
|
### P0: Beranda Quick Access + Header Context
|
||||||
|
File: `lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||||
|
- Qur'an card: `bookOpen` -> `quran-02-solid-sharp`
|
||||||
|
- Murattal: `headphones` -> `headset-solid-rounded`
|
||||||
|
- Dzikir: `sparkles` -> `tasbih`
|
||||||
|
- Doa: `heart` -> `dua-solid-sharp`
|
||||||
|
- Hadits: `library` -> `books-01-solid-standard`
|
||||||
|
- Qibla button/location: `compass/mapPin` -> `compass-duotone-rounded` + `mosque-location`
|
||||||
|
- Prayer state icons (`sunrise/sun/cloudSun/sunset/moon`) -> matching HugeIcons weather/time family
|
||||||
|
|
||||||
|
### P0: Lainnya (Tools)
|
||||||
|
File: `lib/features/tools/presentation/tools_screen.dart`
|
||||||
|
- Same feature mapping as Beranda quick access.
|
||||||
|
- App-bar/system icons remain generic HugeIcons stroke-rounded (`bell`, `settings`, `share`).
|
||||||
|
|
||||||
|
### P0: Qibla Screen
|
||||||
|
File: `lib/features/qibla/presentation/qibla_screen.dart`
|
||||||
|
- Back: `arrowLeft` -> `arrow-left-01-stroke-rounded`
|
||||||
|
- Live state: `locate/locateOff` -> location-focus/location-off family
|
||||||
|
- Location marker: `mapPin` -> `mosque-location` (preferred) or `location-01`
|
||||||
|
|
||||||
|
### P1: Qur'an, Dzikir, Doa, Hadits
|
||||||
|
- Qur'an family files: prioritize Qur'an/bookmark/media semantics.
|
||||||
|
- Dzikir screen: `fingerprint` may remain if preferred UX metaphor; alternative `tasbih` for stronger Islamic tone.
|
||||||
|
- Doa/Hadits list screens: keep simple system icon replacements for `search`, `refresh`.
|
||||||
|
|
||||||
|
### P2: Settings / Checklist / Laporan
|
||||||
|
- Mostly generic system icons; migrate to HugeIcons equivalents with minimal semantic risk.
|
||||||
|
|
||||||
|
## Proposed Implementation Contract
|
||||||
|
1. Introduce wrapper layer (single source of truth): `lib/app/icons/app_icons.dart`
|
||||||
|
2. Keep semantic names stable in app code (`AppIcons.quran`, `AppIcons.dzikir`, `AppIcons.qibla`, etc.).
|
||||||
|
3. Map `AppIcons.*` to HugeIcons package symbols.
|
||||||
|
4. Perform migration by feature slices (P0 -> P2), with visual QA after each slice.
|
||||||
|
|
||||||
|
## QA Checklist
|
||||||
|
- Bottom nav recognizability retained at 24dp.
|
||||||
|
- Active/inactive contrast unchanged.
|
||||||
|
- No icon clipping at 20/24dp sizes.
|
||||||
|
- Islamic features feel more explicit (Qur'an, Dzikir, Doa, Qibla).
|
||||||
|
- Dark/light mode visual consistency maintained.
|
||||||
|
|
||||||
|
## Reference Links (HugeIcons)
|
||||||
|
- https://hugeicons.com/icon/quran-02-solid-sharp
|
||||||
|
- https://hugeicons.com/icon/tasbih
|
||||||
|
- https://hugeicons.com/icon/mosque-location
|
||||||
|
- https://hugeicons.com/icon/kaaba-01-stroke-standard
|
||||||
|
- https://hugeicons.com/icon/dua-solid-sharp
|
||||||
|
- https://hugeicons.com/icon/books-01-solid-standard
|
||||||
|
- https://hugeicons.com/icon/home-01-stroke-rounded
|
||||||
|
- https://hugeicons.com/icon/calendar-01
|
||||||
|
- https://hugeicons.com/icon/check-list
|
||||||
|
- https://hugeicons.com/icon/magic-wand-01-duotone-rounded
|
||||||
|
- https://hugeicons.com/icon/bar-chart-03-stroke-rounded
|
||||||
|
- https://hugeicons.com/icon/compass-duotone-rounded
|
||||||
|
- https://hugeicons.com/icon/headset-solid-rounded
|
||||||
34
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
**/dgph
|
||||||
|
*.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
*.moved-aside
|
||||||
|
*.pbxuser
|
||||||
|
*.perspectivev3
|
||||||
|
**/*sync/
|
||||||
|
.sconsign.dblite
|
||||||
|
.tags*
|
||||||
|
**/.vagrant/
|
||||||
|
**/DerivedData/
|
||||||
|
Icon?
|
||||||
|
**/Pods/
|
||||||
|
**/.symlinks/
|
||||||
|
profile
|
||||||
|
xcuserdata
|
||||||
|
**/.generated/
|
||||||
|
Flutter/App.framework
|
||||||
|
Flutter/Flutter.framework
|
||||||
|
Flutter/Flutter.podspec
|
||||||
|
Flutter/Generated.xcconfig
|
||||||
|
Flutter/ephemeral/
|
||||||
|
Flutter/app.flx
|
||||||
|
Flutter/app.zip
|
||||||
|
Flutter/flutter_assets/
|
||||||
|
Flutter/flutter_export_environment.sh
|
||||||
|
ServiceDefinitions.json
|
||||||
|
Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!default.mode1v3
|
||||||
|
!default.mode2v3
|
||||||
|
!default.pbxuser
|
||||||
|
!default.perspectivev3
|
||||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
2
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// This is a generated file; do not edit or check into version control.
|
// This is a generated file; do not edit or check into version control.
|
||||||
FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter
|
FLUTTER_ROOT=/opt/homebrew/share/flutter
|
||||||
FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary
|
FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary
|
||||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||||
FLUTTER_TARGET=lib/main.dart
|
FLUTTER_TARGET=lib/main.dart
|
||||||
FLUTTER_BUILD_DIR=build
|
FLUTTER_BUILD_DIR=build
|
||||||
|
|||||||
2
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# This is a generated file; do not edit or check into version control.
|
# This is a generated file; do not edit or check into version control.
|
||||||
export "FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter"
|
export "FLUTTER_ROOT=/opt/homebrew/share/flutter"
|
||||||
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary"
|
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary"
|
||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||||
export "FLUTTER_TARGET=lib/main.dart"
|
export "FLUTTER_TARGET=lib/main.dart"
|
||||||
export "FLUTTER_BUILD_DIR=build"
|
export "FLUTTER_BUILD_DIR=build"
|
||||||
|
|||||||
620
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
|
remoteInfo = Runner;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Embed Frameworks";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
);
|
||||||
|
path = RunnerTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = RunnerTests;
|
||||||
|
productName = RunnerTests;
|
||||||
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1510;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
331C8080294A63A400263BE5 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D = {
|
||||||
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
331C807F294A63A400263BE5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
331C807D294A63A400263BE5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C146FB1CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = Main.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147041CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
97C147061CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147071CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatDiary;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
331C8088294A63A400263BE5 /* Debug */,
|
||||||
|
331C8089294A63A400263BE5 /* Release */,
|
||||||
|
331C808A294A63A400263BE5 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147031CF9000F007C117D /* Debug */,
|
||||||
|
97C147041CF9000F007C117D /* Release */,
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
|
97C147071CF9000F007C117D /* Release */,
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
16
ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "Icon-App-1024x1024@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Launch Screen Assets
|
||||||
|
|
||||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||||
|
|
||||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -60,6 +60,12 @@
|
|||||||
@import package_info_plus;
|
@import package_info_plus;
|
||||||
#endif
|
#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>)
|
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
|
||||||
#import <sqflite_darwin/SqflitePlugin.h>
|
#import <sqflite_darwin/SqflitePlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -84,6 +90,7 @@
|
|||||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
|
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
|
||||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||||
|
[FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]];
|
||||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||||
}
|
}
|
||||||
|
|||||||
74
ios/Runner/Info.plist
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Jamshalat Diary</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>jamshalat_diary</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testExample() {
|
||||||
|
// If you add code to the Runner application, consider adding tests here.
|
||||||
|
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,16 +1,80 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' show ViewFocusEvent, ViewFocusState;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../core/providers/theme_provider.dart';
|
import '../core/providers/theme_provider.dart';
|
||||||
|
import '../features/dashboard/data/prayer_times_provider.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
|
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
|
||||||
class App extends ConsumerWidget {
|
class App extends ConsumerStatefulWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
|
|
||||||
@override
|
@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);
|
final themeMode = ref.watch(themeProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
|
|||||||
119
lib/app/icons/app_icons.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hugeicons/hugeicons.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AppIconGlyph {
|
||||||
|
const AppIconGlyph.material(this.material) : huge = null;
|
||||||
|
const AppIconGlyph.huge(this.huge) : material = null;
|
||||||
|
|
||||||
|
final IconData? material;
|
||||||
|
final List<List<dynamic>>? huge;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppIcon extends StatelessWidget {
|
||||||
|
const AppIcon({
|
||||||
|
super.key,
|
||||||
|
required this.glyph,
|
||||||
|
this.color,
|
||||||
|
this.size,
|
||||||
|
this.strokeWidth,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppIconGlyph glyph;
|
||||||
|
final Color? color;
|
||||||
|
final double? size;
|
||||||
|
final double? strokeWidth;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final huge = glyph.huge;
|
||||||
|
if (huge != null) {
|
||||||
|
return HugeIcon(
|
||||||
|
icon: huge,
|
||||||
|
color: color,
|
||||||
|
size: size,
|
||||||
|
strokeWidth: strokeWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon(
|
||||||
|
glyph.material,
|
||||||
|
color: color,
|
||||||
|
size: size,
|
||||||
|
semanticLabel: semanticLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppIcons {
|
||||||
|
const AppIcons._();
|
||||||
|
|
||||||
|
static const AppIconGlyph home =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedHome01);
|
||||||
|
static const AppIconGlyph calendar =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedCalendar01);
|
||||||
|
static const AppIconGlyph quran =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran02);
|
||||||
|
static const AppIconGlyph dzikir =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedTasbih);
|
||||||
|
static const AppIconGlyph lainnya =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedGridView);
|
||||||
|
static const AppIconGlyph ibadah =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckList);
|
||||||
|
static const AppIconGlyph laporan =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedChart01);
|
||||||
|
|
||||||
|
static const AppIconGlyph murattal =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedHeadset);
|
||||||
|
static const AppIconGlyph qibla =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedCompass);
|
||||||
|
static const AppIconGlyph doa = AppIconGlyph.huge(HugeIcons.strokeRoundedDua);
|
||||||
|
static const AppIconGlyph hadits =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedBooks01);
|
||||||
|
static const AppIconGlyph quranEnrichment =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran01);
|
||||||
|
|
||||||
|
static const AppIconGlyph notification =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedNotification03);
|
||||||
|
static const AppIconGlyph settings =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedSettings02);
|
||||||
|
static const AppIconGlyph share =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedShare01);
|
||||||
|
static const AppIconGlyph themeMoon =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedMoon02);
|
||||||
|
static const AppIconGlyph themeSun =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedSun02);
|
||||||
|
static const AppIconGlyph checkCircle =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckmarkCircle02);
|
||||||
|
static const AppIconGlyph circle =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedCircle);
|
||||||
|
static const AppIconGlyph musicNote =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedMusicNote01);
|
||||||
|
static const AppIconGlyph shuffle =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedShuffle);
|
||||||
|
static const AppIconGlyph previousTrack =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedPrevious);
|
||||||
|
static const AppIconGlyph nextTrack =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedNext);
|
||||||
|
static const AppIconGlyph play =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedPlay);
|
||||||
|
static const AppIconGlyph pause =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedPause);
|
||||||
|
static const AppIconGlyph playlist =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedPlaylist01);
|
||||||
|
static const AppIconGlyph user =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedUser03);
|
||||||
|
static const AppIconGlyph arrowDown =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowDown01);
|
||||||
|
|
||||||
|
static const AppIconGlyph backArrow =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowLeft01);
|
||||||
|
static const AppIconGlyph location =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedMosqueLocation);
|
||||||
|
static const AppIconGlyph locationActive =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedLocation01);
|
||||||
|
static const AppIconGlyph locationOffline =
|
||||||
|
AppIconGlyph.huge(HugeIcons.strokeRoundedLocationOffline01);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import '../data/local/hive_boxes.dart';
|
import '../data/local/hive_boxes.dart';
|
||||||
@@ -11,11 +13,15 @@ import '../features/checklist/presentation/checklist_screen.dart';
|
|||||||
import '../features/laporan/presentation/laporan_screen.dart';
|
import '../features/laporan/presentation/laporan_screen.dart';
|
||||||
import '../features/tools/presentation/tools_screen.dart';
|
import '../features/tools/presentation/tools_screen.dart';
|
||||||
import '../features/dzikir/presentation/dzikir_screen.dart';
|
import '../features/dzikir/presentation/dzikir_screen.dart';
|
||||||
|
import '../features/doa/presentation/doa_screen.dart';
|
||||||
|
import '../features/hadits/presentation/hadits_screen.dart';
|
||||||
import '../features/qibla/presentation/qibla_screen.dart';
|
import '../features/qibla/presentation/qibla_screen.dart';
|
||||||
import '../features/quran/presentation/quran_screen.dart';
|
import '../features/quran/presentation/quran_screen.dart';
|
||||||
import '../features/quran/presentation/quran_reading_screen.dart';
|
import '../features/quran/presentation/quran_reading_screen.dart';
|
||||||
import '../features/quran/presentation/quran_murattal_screen.dart';
|
import '../features/quran/presentation/quran_murattal_screen.dart';
|
||||||
import '../features/quran/presentation/quran_bookmarks_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';
|
import '../features/settings/presentation/settings_screen.dart';
|
||||||
|
|
||||||
/// Navigation key for the shell navigator (bottom-nav screens).
|
/// Navigation key for the shell navigator (bottom-nav screens).
|
||||||
@@ -30,12 +36,16 @@ final GoRouter appRouter = GoRouter(
|
|||||||
// ── Shell route (bottom nav persists) ──
|
// ── Shell route (bottom nav persists) ──
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
navigatorKey: _shellNavigatorKey,
|
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: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: DashboardScreen(),
|
key: state.pageKey,
|
||||||
|
child: const DashboardScreen(),
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -47,26 +57,30 @@ final GoRouter appRouter = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/imsakiyah',
|
path: '/imsakiyah',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: ImsakiyahScreen(),
|
key: state.pageKey,
|
||||||
|
child: const ImsakiyahScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/checklist',
|
path: '/checklist',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: ChecklistScreen(),
|
key: state.pageKey,
|
||||||
|
child: const ChecklistScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/laporan',
|
path: '/laporan',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: LaporanScreen(),
|
key: state.pageKey,
|
||||||
|
child: const LaporanScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tools',
|
path: '/tools',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
child: ToolsScreen(),
|
key: state.pageKey,
|
||||||
|
child: const ToolsScreen(),
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -79,6 +93,11 @@ final GoRouter appRouter = GoRouter(
|
|||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) => const QuranScreen(),
|
builder: (context, state) => const QuranScreen(),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'enrichment',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const QuranEnrichmentScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'bookmarks',
|
path: 'bookmarks',
|
||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
@@ -89,8 +108,10 @@ final GoRouter appRouter = GoRouter(
|
|||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final surahId = state.pathParameters['surahId']!;
|
final surahId = state.pathParameters['surahId']!;
|
||||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
final startVerse = int.tryParse(
|
||||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
|
state.uri.queryParameters['startVerse'] ?? '');
|
||||||
|
return QuranReadingScreen(
|
||||||
|
surahId: surahId, initialVerse: startVerse);
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -99,7 +120,8 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final surahId = state.pathParameters['surahId']!;
|
final surahId = state.pathParameters['surahId']!;
|
||||||
final qariId = state.uri.queryParameters['qariId'];
|
final qariId = state.uri.queryParameters['qariId'];
|
||||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
final autoplay =
|
||||||
|
state.uri.queryParameters['autoplay'] == 'true';
|
||||||
return QuranMurattalScreen(
|
return QuranMurattalScreen(
|
||||||
surahId: surahId,
|
surahId: surahId,
|
||||||
initialQariId: qariId,
|
initialQariId: qariId,
|
||||||
@@ -116,28 +138,49 @@ final GoRouter appRouter = GoRouter(
|
|||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
builder: (context, state) => const QiblaScreen(),
|
builder: (context, state) => const QiblaScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'doa',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const DoaScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'hadits',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const HaditsScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Simple Mode Tab: Zikir
|
// Simple Mode Tab: Zikir
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dzikir',
|
path: '/dzikir',
|
||||||
builder: (context, state) => const DzikirScreen(isSimpleModeTab: true),
|
builder: (context, state) =>
|
||||||
|
const DzikirScreen(isSimpleModeTab: true),
|
||||||
),
|
),
|
||||||
// Simple Mode Tab: Tilawah
|
// Simple Mode Tab: Tilawah
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/quran',
|
path: '/quran',
|
||||||
builder: (context, state) => const QuranScreen(isSimpleModeTab: true),
|
builder: (context, state) => const QuranScreen(isSimpleModeTab: true),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'enrichment',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const QuranEnrichmentScreen(isSimpleModeTab: true),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'bookmarks',
|
path: 'bookmarks',
|
||||||
builder: (context, state) => const QuranBookmarksScreen(),
|
builder: (context, state) =>
|
||||||
|
const QuranBookmarksScreen(isSimpleModeTab: true),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':surahId',
|
path: ':surahId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final surahId = state.pathParameters['surahId']!;
|
final surahId = state.pathParameters['surahId']!;
|
||||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
final startVerse =
|
||||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse, isSimpleModeTab: true);
|
int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||||
|
return QuranReadingScreen(
|
||||||
|
surahId: surahId,
|
||||||
|
initialVerse: startVerse,
|
||||||
|
isSimpleModeTab: true);
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -146,7 +189,8 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final surahId = state.pathParameters['surahId']!;
|
final surahId = state.pathParameters['surahId']!;
|
||||||
final qariId = state.uri.queryParameters['qariId'];
|
final qariId = state.uri.queryParameters['qariId'];
|
||||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
final autoplay =
|
||||||
|
state.uri.queryParameters['autoplay'] == 'true';
|
||||||
return QuranMurattalScreen(
|
return QuranMurattalScreen(
|
||||||
surahId: surahId,
|
surahId: surahId,
|
||||||
initialQariId: qariId,
|
initialQariId: qariId,
|
||||||
@@ -159,9 +203,23 @@ final GoRouter appRouter = GoRouter(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/doa',
|
||||||
|
builder: (context, state) => const DoaScreen(isSimpleModeTab: true),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/hadits',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const HaditsScreen(isSimpleModeTab: true),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// ── Settings (pushed, no bottom nav) ──
|
// ── Settings (pushed, no bottom nav) ──
|
||||||
|
GoRoute(
|
||||||
|
path: '/notifications',
|
||||||
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
|
builder: (context, state) => const NotificationCenterScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
parentNavigatorKey: _rootNavigatorKey,
|
parentNavigatorKey: _rootNavigatorKey,
|
||||||
@@ -171,11 +229,31 @@ final GoRouter appRouter = GoRouter(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Scaffold wrapper that provides the persistent bottom nav bar.
|
/// Scaffold wrapper that provides the persistent bottom nav bar.
|
||||||
class _ScaffoldWithNav extends StatelessWidget {
|
class _ScaffoldWithNav extends StatefulWidget {
|
||||||
const _ScaffoldWithNav({required this.child});
|
const _ScaffoldWithNav({required this.child});
|
||||||
|
|
||||||
final Widget 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.
|
/// Maps route locations to bottom nav indices.
|
||||||
int _currentIndex(BuildContext context) {
|
int _currentIndex(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).uri.toString();
|
final location = GoRouterState.of(context).uri.toString();
|
||||||
@@ -184,9 +262,13 @@ class _ScaffoldWithNav extends StatelessWidget {
|
|||||||
|
|
||||||
if (isSimpleMode) {
|
if (isSimpleMode) {
|
||||||
if (location.startsWith('/imsakiyah')) return 1;
|
if (location.startsWith('/imsakiyah')) return 1;
|
||||||
if (location.startsWith('/quran') && !location.contains('/murattal')) return 2;
|
if (location.startsWith('/quran')) return 2;
|
||||||
if (location.contains('/murattal')) return 3;
|
if (location.startsWith('/dzikir')) return 3;
|
||||||
if (location.startsWith('/dzikir')) return 4;
|
if (location.startsWith('/tools') ||
|
||||||
|
location.startsWith('/doa') ||
|
||||||
|
location.startsWith('/hadits')) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
if (location.startsWith('/imsakiyah')) return 1;
|
if (location.startsWith('/imsakiyah')) return 1;
|
||||||
@@ -213,10 +295,10 @@ class _ScaffoldWithNav extends StatelessWidget {
|
|||||||
context.go('/quran');
|
context.go('/quran');
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
context.push('/quran/1/murattal');
|
context.go('/dzikir');
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
context.go('/dzikir');
|
context.go('/tools');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -240,17 +322,98 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<Box<AppSettings>>(
|
return ValueListenableBuilder<Box<AppSettings>>(
|
||||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||||
builder: (context, box, _) {
|
builder: (context, box, _) {
|
||||||
return Scaffold(
|
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
||||||
body: child,
|
final path = GoRouterState.of(context).uri.path;
|
||||||
bottomNavigationBar: AppBottomNavBar(
|
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),
|
currentIndex: _currentIndex(context),
|
||||||
onTap: (i) => _onTap(context, i),
|
onTap: (i) => _onTap(context, i),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,29 +4,55 @@ import 'package:flutter/material.dart';
|
|||||||
class AppColors {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ── Primary ──
|
// ── Brand tokens: logo palette (teal + gold) ──
|
||||||
static const Color primary = Color(0xFF70DF20);
|
static const Color brandTeal500 = Color(0xFF118A8D);
|
||||||
static const Color onPrimary = Color(0xFF0A1A00);
|
static const Color brandTeal700 = Color(0xFF0C676A);
|
||||||
|
static const Color brandTeal900 = Color(0xFF0A4447);
|
||||||
|
|
||||||
// ── Background ──
|
static const Color brandGold200 = Color(0xFFF6DE96);
|
||||||
static const Color backgroundLight = Color(0xFFF7F8F6);
|
static const Color brandGold300 = Color(0xFFE9C75B);
|
||||||
static const Color backgroundDark = Color(0xFF182111);
|
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 surfaceLight = Color(0xFFFFFFFF);
|
||||||
static const Color surfaceDark = Color(0xFF1E2A14);
|
static const Color surfaceLightElevated = Color(0xFFF9FAFB);
|
||||||
|
|
||||||
// ── Sage (secondary text / section labels) ──
|
static const Color surfaceDark = Color(0xFF171B22);
|
||||||
static const Color sage = Color(0xFF728764);
|
static const Color surfaceDarkElevated = Color(0xFF1D222B);
|
||||||
|
|
||||||
// ── Cream (dividers, borders — light mode only) ──
|
static const Color textPrimaryLight = Color(0xFF1F2937);
|
||||||
static const Color cream = Color(0xFFF2F4F0);
|
static const Color textPrimaryDark = Color(0xFFE8ECF2);
|
||||||
|
static const Color textSecondaryLight = Color(0xFF6B7280);
|
||||||
|
static const Color textSecondaryDark = Color(0xFF9AA4B2);
|
||||||
|
|
||||||
// ── Text ──
|
// ── Compatibility aliases (existing UI references) ──
|
||||||
static const Color textPrimaryLight = Color(0xFF1A2A0A);
|
static const Color primary = brandTeal500;
|
||||||
static const Color textPrimaryDark = Color(0xFFF2F4F0);
|
static const Color onPrimary = Color(0xFFFFFFFF);
|
||||||
static const Color textSecondaryLight = Color(0xFF64748B);
|
static const Color sage = brandTeal700;
|
||||||
static const Color textSecondaryDark = Color(0xFF94A3B8);
|
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 ──
|
// ── Semantic ──
|
||||||
static const Color errorLight = Color(0xFFEF4444);
|
static const Color errorLight = Color(0xFFEF4444);
|
||||||
@@ -36,25 +62,29 @@ class AppColors {
|
|||||||
|
|
||||||
// ── Convenience helpers for theme building ──
|
// ── Convenience helpers for theme building ──
|
||||||
|
|
||||||
static ColorScheme get lightColorScheme => ColorScheme.light(
|
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||||
primary: primary,
|
primary: primary,
|
||||||
onPrimary: onPrimary,
|
onPrimary: onPrimary,
|
||||||
|
primaryContainer: brandTeal700,
|
||||||
|
onPrimaryContainer: Colors.white,
|
||||||
surface: surfaceLight,
|
surface: surfaceLight,
|
||||||
onSurface: textPrimaryLight,
|
onSurface: textPrimaryLight,
|
||||||
error: errorLight,
|
error: errorLight,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
secondary: sage,
|
secondary: navActiveGold,
|
||||||
onSecondary: Colors.white,
|
onSecondary: brandGold700,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ColorScheme get darkColorScheme => ColorScheme.dark(
|
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||||
primary: primary,
|
primary: primary,
|
||||||
onPrimary: onPrimary,
|
onPrimary: onPrimary,
|
||||||
|
primaryContainer: brandTeal900,
|
||||||
|
onPrimaryContainer: textPrimaryDark,
|
||||||
surface: surfaceDark,
|
surface: surfaceDark,
|
||||||
onSurface: textPrimaryDark,
|
onSurface: textPrimaryDark,
|
||||||
error: errorDark,
|
error: errorDark,
|
||||||
onError: Colors.black,
|
onError: Colors.black,
|
||||||
secondary: sage,
|
secondary: navActiveGold,
|
||||||
onSecondary: Colors.white,
|
onSecondary: brandGold200,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Typography definitions from PRD §3.2.
|
/// 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 {
|
class AppTextStyles {
|
||||||
AppTextStyles._();
|
AppTextStyles._();
|
||||||
|
|
||||||
static const String _fontFamily = 'PlusJakartaSans';
|
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.
|
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
|
||||||
static const TextTheme textTheme = TextTheme(
|
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(
|
static const TextStyle arabicBody = TextStyle(
|
||||||
fontFamily: 'Amiri',
|
fontFamily: _arabicFontFamily,
|
||||||
|
fontFamilyFallback: _arabicFallbackFamilies,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
height: 2.0,
|
height: 1.8,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle arabicLarge = TextStyle(
|
static const TextStyle arabicLarge = TextStyle(
|
||||||
fontFamily: 'Amiri',
|
fontFamily: _arabicFontFamily,
|
||||||
|
fontFamilyFallback: _arabicFallbackFamilies,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w400,
|
||||||
height: 2.2,
|
height: 2.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,17 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||||
backgroundColor: AppColors.surfaceLight,
|
backgroundColor: AppColors.surfaceLight,
|
||||||
selectedItemColor: AppColors.primary,
|
selectedItemColor: AppColors.navActiveGoldDeep,
|
||||||
unselectedItemColor: AppColors.textSecondaryLight,
|
unselectedItemColor: AppColors.textSecondaryLight,
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: AppColors.surfaceLight,
|
color: AppColors.surfaceLightElevated,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(
|
side: const BorderSide(
|
||||||
color: AppColors.cream,
|
color: AppColors.cream,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -70,21 +70,21 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||||
backgroundColor: AppColors.surfaceDark,
|
backgroundColor: AppColors.surfaceDark,
|
||||||
selectedItemColor: AppColors.primary,
|
selectedItemColor: AppColors.navActiveGold,
|
||||||
unselectedItemColor: AppColors.textSecondaryDark,
|
unselectedItemColor: AppColors.textSecondaryDark,
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: AppColors.surfaceDark,
|
color: AppColors.surfaceDarkElevated,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: AppColors.primary.withValues(alpha: 0.1),
|
color: AppColors.brandTeal500.withValues(alpha: 0.22),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dividerColor: AppColors.surfaceDark,
|
dividerColor: AppColors.surfaceDarkElevated,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/core/services/app_audio_player.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
/// Shared app-wide audio player.
|
||||||
|
///
|
||||||
|
/// `just_audio_background` supports only one `AudioPlayer` instance, so all
|
||||||
|
/// playback surfaces should reuse this singleton.
|
||||||
|
class AppAudioPlayer {
|
||||||
|
AppAudioPlayer._();
|
||||||
|
|
||||||
|
static final AudioPlayer instance = AudioPlayer();
|
||||||
|
}
|
||||||
94
lib/core/widgets/arabic_text.dart
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
import '../../data/local/hive_boxes.dart';
|
||||||
|
import '../../data/local/models/app_settings.dart';
|
||||||
|
|
||||||
|
/// Arabic text widget that reacts to [AppSettings.arabicFontSize].
|
||||||
|
///
|
||||||
|
/// `baseFontSize` keeps per-screen visual hierarchy while still following
|
||||||
|
/// global user preference from Settings.
|
||||||
|
class ArabicText extends StatelessWidget {
|
||||||
|
const ArabicText(
|
||||||
|
this.data, {
|
||||||
|
super.key,
|
||||||
|
this.baseFontSize = 24,
|
||||||
|
this.fontWeight = FontWeight.w400,
|
||||||
|
this.height,
|
||||||
|
this.color,
|
||||||
|
this.textAlign,
|
||||||
|
this.maxLines,
|
||||||
|
this.overflow,
|
||||||
|
this.textDirection,
|
||||||
|
this.fontStyle,
|
||||||
|
this.letterSpacing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String data;
|
||||||
|
final double baseFontSize;
|
||||||
|
final FontWeight fontWeight;
|
||||||
|
final double? height;
|
||||||
|
final Color? color;
|
||||||
|
final TextAlign? textAlign;
|
||||||
|
final int? maxLines;
|
||||||
|
final TextOverflow? overflow;
|
||||||
|
final TextDirection? textDirection;
|
||||||
|
final FontStyle? fontStyle;
|
||||||
|
final double? letterSpacing;
|
||||||
|
static const double _explicitLineHeightCompression = 0.9;
|
||||||
|
static const double _defaultArabicLineHeight = 1.8;
|
||||||
|
static const String _primaryArabicFontFamily = 'ScheherazadeNew';
|
||||||
|
static const List<String> _arabicFallbackFamilies = <String>[
|
||||||
|
'UthmanTahaNaskh',
|
||||||
|
'KFGQPCUthmanicHafs',
|
||||||
|
'Amiri',
|
||||||
|
'Noto Naskh Arabic',
|
||||||
|
'Noto Sans Arabic',
|
||||||
|
'Droid Arabic Naskh',
|
||||||
|
'sans-serif',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<Box<AppSettings>>(
|
||||||
|
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings)
|
||||||
|
.listenable(keys: ['default']),
|
||||||
|
builder: (_, box, __) {
|
||||||
|
final preferredSize = box.get('default')?.arabicFontSize ?? 24.0;
|
||||||
|
final adjustedSize = (baseFontSize + (preferredSize - 24.0))
|
||||||
|
.clamp(12.0, 56.0)
|
||||||
|
.toDouble();
|
||||||
|
final effectiveHeight = height == null
|
||||||
|
? _defaultArabicLineHeight
|
||||||
|
: (height! * _explicitLineHeightCompression)
|
||||||
|
.clamp(1.6, 2.35)
|
||||||
|
.toDouble();
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
data,
|
||||||
|
textAlign: textAlign,
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: overflow,
|
||||||
|
textDirection: textDirection,
|
||||||
|
strutStyle: StrutStyle(
|
||||||
|
fontFamily: _primaryArabicFontFamily,
|
||||||
|
fontSize: adjustedSize,
|
||||||
|
height: effectiveHeight,
|
||||||
|
leading: 0.08,
|
||||||
|
forceStrutHeight: true,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: _primaryArabicFontFamily,
|
||||||
|
fontFamilyFallback: _arabicFallbackFamilies,
|
||||||
|
fontSize: adjustedSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
height: effectiveHeight,
|
||||||
|
color: color,
|
||||||
|
fontStyle: fontStyle,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
686
lib/core/widgets/ayat_share_sheet.dart
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../../app/icons/app_icons.dart';
|
||||||
|
import '../../app/theme/app_colors.dart';
|
||||||
|
import '../../core/widgets/arabic_text.dart';
|
||||||
|
|
||||||
|
String buildAyatShareText(Map<String, dynamic> ayat) {
|
||||||
|
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||||
|
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||||
|
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||||
|
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||||
|
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||||
|
? 'QS. $surahName: $verseNumber'
|
||||||
|
: 'Ayat Hari Ini';
|
||||||
|
|
||||||
|
final parts = <String>[
|
||||||
|
if (arabic.isNotEmpty) arabic,
|
||||||
|
if (translation.isNotEmpty) '"$translation"',
|
||||||
|
reference,
|
||||||
|
'Dibagikan dari Jam Shalat Diary',
|
||||||
|
];
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showAyatShareSheet(
|
||||||
|
BuildContext context,
|
||||||
|
Map<String, dynamic> ayat,
|
||||||
|
) async {
|
||||||
|
final shareText = buildAyatShareText(ayat);
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
Future<void> handleShareImage() async {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final pngBytes = await _captureAyatShareCardPng(context, ayat);
|
||||||
|
final file = await _writeAyatShareImage(pngBytes);
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(file.path)],
|
||||||
|
text: 'Ayat Hari Ini',
|
||||||
|
subject: 'Ayat Hari Ini',
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Gagal menyiapkan gambar ayat'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleShareText() async {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
await Share.share(
|
||||||
|
shareText,
|
||||||
|
subject: 'Ayat Hari Ini',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleCopyText() async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: shareText));
|
||||||
|
if (!sheetContext.mounted) return;
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Teks ayat disalin ke clipboard'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Bagikan Ayat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Pilih cara tercepat untuk membagikan ayat hari ini.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_AyatShareActionTile(
|
||||||
|
icon: const Icon(
|
||||||
|
LucideIcons.image,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
title: 'Bagikan Gambar',
|
||||||
|
subtitle: 'Kirim kartu ayat yang siap dibagikan',
|
||||||
|
badge: 'Utama',
|
||||||
|
onTap: handleShareImage,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_AyatShareActionTile(
|
||||||
|
icon: const AppIcon(
|
||||||
|
glyph: AppIcons.share,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
title: 'Bagikan Teks',
|
||||||
|
subtitle: 'Kirim ayat dan terjemahan ke aplikasi lain',
|
||||||
|
onTap: handleShareText,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_AyatShareActionTile(
|
||||||
|
icon: const Icon(
|
||||||
|
LucideIcons.copy,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
title: 'Salin Teks',
|
||||||
|
subtitle: 'Simpan ke clipboard untuk ditempel manual',
|
||||||
|
onTap: handleCopyText,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _captureAyatShareCardPng(
|
||||||
|
BuildContext context,
|
||||||
|
Map<String, dynamic> ayat,
|
||||||
|
) async {
|
||||||
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
|
final boundaryKey = GlobalKey();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final textDirection = Directionality.of(context);
|
||||||
|
|
||||||
|
late final OverlayEntry entry;
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (_) => IgnorePointer(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.01,
|
||||||
|
child: MediaQuery(
|
||||||
|
data: mediaQuery,
|
||||||
|
child: Theme(
|
||||||
|
data: theme,
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: textDirection,
|
||||||
|
child: UnconstrainedBox(
|
||||||
|
constrainedAxis: Axis.horizontal,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
key: boundaryKey,
|
||||||
|
child: _AyatShareCard(
|
||||||
|
ayat: ayat,
|
||||||
|
isDark: theme.brightness == Brightness.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
overlay.insert(entry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
final boundary = boundaryKey.currentContext?.findRenderObject()
|
||||||
|
as RenderRepaintBoundary?;
|
||||||
|
if (boundary == null) {
|
||||||
|
throw StateError('Ayat share card is not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
final image = await boundary.toImage(pixelRatio: 3);
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
if (byteData == null) {
|
||||||
|
throw StateError('Failed to encode ayat share card');
|
||||||
|
}
|
||||||
|
return byteData.buffer.asUint8List();
|
||||||
|
} finally {
|
||||||
|
entry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _writeAyatShareImage(Uint8List pngBytes) async {
|
||||||
|
final directory = await Directory.systemTemp.createTemp('jamshalat_ayat_');
|
||||||
|
final file = File('${directory.path}/ayat_hari_ini.png');
|
||||||
|
await file.writeAsBytes(pngBytes, flush: true);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AyatShareCard extends StatelessWidget {
|
||||||
|
const _AyatShareCard({
|
||||||
|
required this.ayat,
|
||||||
|
required this.isDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> ayat;
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final arabic = (ayat['teksArab'] ?? '').toString().trim();
|
||||||
|
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
|
||||||
|
final surahName = (ayat['surahName'] ?? '').toString().trim();
|
||||||
|
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
|
||||||
|
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
|
||||||
|
? 'QS. $surahName: $verseNumber'
|
||||||
|
: 'Ayat Hari Ini';
|
||||||
|
final isLongArabic = arabic.length > 120;
|
||||||
|
final isVeryLongArabic = arabic.length > 180;
|
||||||
|
final isLongTranslation = translation.length > 140;
|
||||||
|
final isVeryLongTranslation = translation.length > 220;
|
||||||
|
final arabicFontSize = isVeryLongArabic
|
||||||
|
? 22.0
|
||||||
|
: isLongArabic
|
||||||
|
? 24.0
|
||||||
|
: 28.0;
|
||||||
|
final arabicHeight = isVeryLongArabic
|
||||||
|
? 1.55
|
||||||
|
: isLongArabic
|
||||||
|
? 1.62
|
||||||
|
: 1.75;
|
||||||
|
final translationFontSize = isVeryLongTranslation
|
||||||
|
? 13.0
|
||||||
|
: isLongTranslation
|
||||||
|
? 14.0
|
||||||
|
: 15.0;
|
||||||
|
final translationHeight = isVeryLongTranslation ? 1.5 : 1.6;
|
||||||
|
final verticalPadding = isVeryLongTranslation ? 22.0 : 24.0;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: isDark
|
||||||
|
? const [
|
||||||
|
Color(0xFF102028),
|
||||||
|
Color(0xFF0F1217),
|
||||||
|
Color(0xFF16343A),
|
||||||
|
]
|
||||||
|
: const [
|
||||||
|
Color(0xFFF6FBFB),
|
||||||
|
Color(0xFFFFFFFF),
|
||||||
|
Color(0xFFEAF7F7),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
AppColors.primary.withValues(alpha: isDark ? 0.24 : 0.12),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _AyatFramePainter(isDark: isDark),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -38,
|
||||||
|
right: -34,
|
||||||
|
child: Container(
|
||||||
|
width: 116,
|
||||||
|
height: 116,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.05),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -46,
|
||||||
|
left: -28,
|
||||||
|
child: Container(
|
||||||
|
width: 132,
|
||||||
|
height: 132,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.navActiveGold.withValues(alpha: 0.05),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
28,
|
||||||
|
28,
|
||||||
|
28,
|
||||||
|
verticalPadding,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.bookMarked,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Ayat Hari Ini',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.3,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
reference,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.textPrimaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (arabic.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
ArabicText(
|
||||||
|
arabic,
|
||||||
|
baseFontSize: arabicFontSize,
|
||||||
|
height: arabicHeight,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textPrimaryDark
|
||||||
|
: AppColors.textPrimaryLight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (translation.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : AppColors.primary)
|
||||||
|
.withValues(alpha: isDark ? 0.05 : 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: (isDark ? Colors.white : AppColors.primary)
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'"$translation"',
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: translationFontSize,
|
||||||
|
height: translationHeight,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textPrimaryDark
|
||||||
|
: AppColors.textPrimaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
AppColors.navActiveGold.withValues(alpha: 0.16),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Jam Shalat Diary',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.navActiveGoldDeep,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'Bagikan kebaikan',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AyatShareActionTile extends StatelessWidget {
|
||||||
|
const _AyatShareActionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
this.badge,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final String? badge;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.primary.withValues(alpha: 0.12)
|
||||||
|
: AppColors.cream,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Center(child: icon),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (badge != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
AppColors.navActiveGold.withValues(alpha: 0.18),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.navActiveGoldDeep,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.4,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 18,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AyatFramePainter extends CustomPainter {
|
||||||
|
const _AyatFramePainter({required this.isDark});
|
||||||
|
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final outerRect = RRect.fromRectAndRadius(
|
||||||
|
Offset.zero & size,
|
||||||
|
const Radius.circular(22),
|
||||||
|
);
|
||||||
|
|
||||||
|
final outerPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.6
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppColors.navActiveGoldPale,
|
||||||
|
AppColors.navActiveGold,
|
||||||
|
AppColors.navActiveGoldDeep,
|
||||||
|
],
|
||||||
|
).createShader(Offset.zero & size);
|
||||||
|
|
||||||
|
final outerGlow = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 5
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
|
||||||
|
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.08 : 0.05);
|
||||||
|
|
||||||
|
final innerBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
|
||||||
|
final innerFrame = RRect.fromRectAndRadius(
|
||||||
|
innerBounds,
|
||||||
|
const Radius.circular(18),
|
||||||
|
);
|
||||||
|
final innerPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 0.8
|
||||||
|
..color = (isDark ? Colors.white : AppColors.primary)
|
||||||
|
.withValues(alpha: isDark ? 0.08 : 0.10);
|
||||||
|
|
||||||
|
canvas.drawRRect(outerRect, outerGlow);
|
||||||
|
canvas.drawRRect(outerRect, outerPaint);
|
||||||
|
canvas.drawRRect(innerFrame, innerPaint);
|
||||||
|
|
||||||
|
_drawMidMotif(canvas, size, top: true);
|
||||||
|
_drawMidMotif(canvas, size, top: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawMidMotif(Canvas canvas, Size size, {required bool top}) {
|
||||||
|
final y = top ? 14.0 : size.height - 14.0;
|
||||||
|
final centerX = size.width / 2;
|
||||||
|
|
||||||
|
final linePaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 0.9
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.26 : 0.22);
|
||||||
|
final diamondPaint = Paint()
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..color = AppColors.primary.withValues(alpha: isDark ? 0.34 : 0.22);
|
||||||
|
final diamondStroke = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 0.9
|
||||||
|
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.58 : 0.48);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX - 26, y),
|
||||||
|
Offset(centerX - 10, y),
|
||||||
|
linePaint,
|
||||||
|
);
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(centerX + 10, y),
|
||||||
|
Offset(centerX + 26, y),
|
||||||
|
linePaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
final diamondPath = Path()
|
||||||
|
..moveTo(centerX, y - 5)
|
||||||
|
..lineTo(centerX + 5, y)
|
||||||
|
..lineTo(centerX, y + 5)
|
||||||
|
..lineTo(centerX - 5, y)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(diamondPath, diamondPaint);
|
||||||
|
canvas.drawPath(diamondPath, diamondStroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _AyatFramePainter oldDelegate) {
|
||||||
|
return oldDelegate.isDark != isDark;
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/core/widgets/ayat_today_card.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/icons/app_icons.dart';
|
||||||
|
import '../../app/theme/app_colors.dart';
|
||||||
|
import '../../data/services/muslim_api_service.dart';
|
||||||
|
import 'arabic_text.dart';
|
||||||
|
import 'ayat_share_sheet.dart';
|
||||||
|
|
||||||
|
class AyatTodayCard extends StatefulWidget {
|
||||||
|
const AyatTodayCard({
|
||||||
|
super.key,
|
||||||
|
required this.headerText,
|
||||||
|
required this.headerStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String headerText;
|
||||||
|
final TextStyle headerStyle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AyatTodayCard> createState() => _AyatTodayCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AyatTodayCardState extends State<AyatTodayCard> {
|
||||||
|
Map<String, dynamic>? _dailyAyat;
|
||||||
|
Map<String, dynamic>? _activeAyat;
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isRandomizing = false;
|
||||||
|
bool _showingRandomAyat = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDailyAyat();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDailyAyat() async {
|
||||||
|
final ayat = await MuslimApiService.instance.getDailyAyat();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dailyAyat = ayat;
|
||||||
|
_activeAyat = ayat;
|
||||||
|
_showingRandomAyat = false;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showRandomAyat() async {
|
||||||
|
if (_isRandomizing || _activeAyat == null) return;
|
||||||
|
|
||||||
|
setState(() => _isRandomizing = true);
|
||||||
|
final randomAyat = await MuslimApiService.instance.getRandomAyat(
|
||||||
|
excludeSurahNumber: _asInt(_activeAyat?['nomorSurah']),
|
||||||
|
excludeAyahNumber: _asInt(_activeAyat?['nomorAyat']),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isRandomizing = false;
|
||||||
|
if (randomAyat != null) {
|
||||||
|
_activeAyat = randomAyat;
|
||||||
|
_showingRandomAyat = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreDailyAyat() {
|
||||||
|
if (_dailyAyat == null) return;
|
||||||
|
setState(() {
|
||||||
|
_activeAyat = _dailyAyat;
|
||||||
|
_showingRandomAyat = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _asInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final backgroundColor = isDark
|
||||||
|
? AppColors.primary.withValues(alpha: 0.08)
|
||||||
|
: const Color(0xFFF5F9F0);
|
||||||
|
|
||||||
|
if (_isLoading) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = _activeAyat;
|
||||||
|
if (data == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final secondaryColor =
|
||||||
|
isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(widget.headerText, style: widget.headerStyle)),
|
||||||
|
IconButton(
|
||||||
|
icon: AppIcon(
|
||||||
|
glyph: AppIcons.share,
|
||||||
|
size: 18,
|
||||||
|
color: secondaryColor,
|
||||||
|
),
|
||||||
|
tooltip: 'Bagikan ayat',
|
||||||
|
onPressed: () => showAyatShareSheet(context, data),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ArabicText(
|
||||||
|
data['teksArab'] ?? '',
|
||||||
|
baseFontSize: 24,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.8,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'"${data['teksIndonesia'] ?? ''}"',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
height: 1.5,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'QS. ${data['surahName']}: ${data['nomorAyat']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isRandomizing ? null : _showRandomAyat,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
backgroundColor: AppColors.primary.withValues(
|
||||||
|
alpha: isDark ? 0.16 : 0.12,
|
||||||
|
),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: _isRandomizing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const AppIcon(
|
||||||
|
glyph: AppIcons.shuffle,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
label: Text(_showingRandomAyat ? 'Acak Lagi' : 'Ayat Lain'),
|
||||||
|
),
|
||||||
|
if (_showingRandomAyat)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _restoreDailyAyat,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: secondaryColor,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Kembali ke Hari Ini'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package: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 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
import '../../app/icons/app_icons.dart';
|
||||||
import '../../app/theme/app_colors.dart';
|
import '../../app/theme/app_colors.dart';
|
||||||
|
import '../../core/providers/theme_provider.dart';
|
||||||
import '../../data/local/hive_boxes.dart';
|
import '../../data/local/hive_boxes.dart';
|
||||||
import '../../data/local/models/app_settings.dart';
|
import '../../data/local/models/app_settings.dart';
|
||||||
|
|
||||||
/// 5-tab bottom navigation bar per PRD §5.1.
|
/// 5-tab bottom navigation bar with luxury active treatment.
|
||||||
/// Uses Material Symbols outlined (inactive) and filled (active).
|
class AppBottomNavBar extends ConsumerStatefulWidget {
|
||||||
class AppBottomNavBar extends StatelessWidget {
|
|
||||||
const AppBottomNavBar({
|
const AppBottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.currentIndex,
|
required this.currentIndex,
|
||||||
@@ -17,86 +22,207 @@ class AppBottomNavBar extends StatelessWidget {
|
|||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
final ValueChanged<int> onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final media = MediaQuery.maybeOf(context);
|
||||||
|
final reducedMotion = (media?.disableAnimations ?? false) ||
|
||||||
|
(media?.accessibleNavigation ?? false);
|
||||||
|
_syncAnimation(reducedMotion: reducedMotion);
|
||||||
|
|
||||||
return ValueListenableBuilder<Box<AppSettings>>(
|
return ValueListenableBuilder<Box<AppSettings>>(
|
||||||
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
|
||||||
builder: (context, box, _) {
|
builder: (context, box, _) {
|
||||||
final isSimpleMode = box.get('default')?.simpleMode ?? false;
|
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 [
|
final items = isSimpleMode
|
||||||
BottomNavigationBarItem(
|
? const [
|
||||||
icon: Icon(LucideIcons.home),
|
_NavDef(AppIcons.home, 'Beranda'),
|
||||||
activeIcon: Icon(LucideIcons.home),
|
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||||
label: 'Beranda',
|
_NavDef(AppIcons.quran, "Al-Qur'an"),
|
||||||
),
|
_NavDef(AppIcons.dzikir, 'Dzikir'),
|
||||||
BottomNavigationBarItem(
|
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||||
icon: Icon(LucideIcons.calendar),
|
]
|
||||||
activeIcon: Icon(LucideIcons.calendar),
|
: const [
|
||||||
label: 'Jadwal',
|
_NavDef(AppIcons.home, 'Beranda'),
|
||||||
),
|
_NavDef(AppIcons.calendar, 'Jadwal'),
|
||||||
BottomNavigationBarItem(
|
_NavDef(AppIcons.ibadah, 'Ibadah'),
|
||||||
icon: Icon(LucideIcons.bookOpen),
|
_NavDef(AppIcons.laporan, 'Laporan'),
|
||||||
activeIcon: Icon(LucideIcons.bookOpen),
|
_NavDef(AppIcons.lainnya, 'Lainnya'),
|
||||||
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 fullItems = const [
|
return ColoredBox(
|
||||||
BottomNavigationBarItem(
|
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||||
icon: Icon(LucideIcons.home),
|
Colors.transparent,
|
||||||
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset),
|
||||||
child: BottomNavigationBar(
|
child: GestureDetector(
|
||||||
currentIndex: currentIndex,
|
behavior: HitTestBehavior.deferToChild,
|
||||||
onTap: onTap,
|
onHorizontalDragStart: _handleHorizontalDragStart,
|
||||||
items: isSimpleMode ? simpleItems : fullItems,
|
onHorizontalDragUpdate: _handleHorizontalDragUpdate,
|
||||||
|
onHorizontalDragEnd: _handleHorizontalDragEnd,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _revealController,
|
||||||
|
builder: (context, child) {
|
||||||
|
final reveal = _revealAnimation.value;
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: reveal < 0.55,
|
||||||
|
child: _ThemeUnderlayBoard(
|
||||||
|
isDark: isDark,
|
||||||
|
reveal: reveal,
|
||||||
|
onTap: () => _toggleThemeMode(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _revealController,
|
||||||
|
builder: (context, child) {
|
||||||
|
final reveal = _revealAnimation.value;
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(-_toggleRevealWidth * reveal, 0),
|
||||||
|
child: _MainNavBoard(
|
||||||
|
items: items,
|
||||||
|
currentIndex: widget.currentIndex,
|
||||||
|
isDark: isDark,
|
||||||
|
reducedMotion: reducedMotion,
|
||||||
|
animation: _shineController,
|
||||||
|
onTap: _handleNavTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,3 +231,477 @@ class AppBottomNavBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ThemeUnderlayBoard extends StatelessWidget {
|
||||||
|
const _ThemeUnderlayBoard({
|
||||||
|
required this.isDark,
|
||||||
|
required this.reveal,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isDark;
|
||||||
|
final double reveal;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = reveal.clamp(0.0, 1.0).toDouble();
|
||||||
|
return Container(
|
||||||
|
height: 54,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A),
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: SizedBox(
|
||||||
|
width: _AppBottomNavBarState._toggleRevealWidth,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
child: Center(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset((1 - t) * 10, 0),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 0.94 + (t * 0.06),
|
||||||
|
child: AppIcon(
|
||||||
|
glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon,
|
||||||
|
size: 24,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.navActiveGoldPale
|
||||||
|
: AppColors.textPrimaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavDef {
|
||||||
|
const _NavDef(this.icon, this.label);
|
||||||
|
|
||||||
|
final AppIconGlyph icon;
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainNavBoard extends StatelessWidget {
|
||||||
|
const _MainNavBoard({
|
||||||
|
required this.items,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.isDark,
|
||||||
|
required this.reducedMotion,
|
||||||
|
required this.animation,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<_NavDef> items;
|
||||||
|
final int currentIndex;
|
||||||
|
final bool isDark;
|
||||||
|
final bool reducedMotion;
|
||||||
|
final Animation<double> animation;
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final background =
|
||||||
|
Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
|
||||||
|
(isDark ? AppColors.surfaceDark : AppColors.surfaceLight);
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(items.length, (index) {
|
||||||
|
final item = items[index];
|
||||||
|
final isSelected = index == currentIndex;
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onTap(index),
|
||||||
|
customBorder: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: Center(
|
||||||
|
child: isSelected
|
||||||
|
? _AnimatedLuxuryActiveIcon(
|
||||||
|
animation: animation,
|
||||||
|
icon: item.icon,
|
||||||
|
isDark: isDark,
|
||||||
|
reducedMotion: reducedMotion,
|
||||||
|
iconSize: _AppBottomNavBarState._activeIconSize,
|
||||||
|
)
|
||||||
|
: _InactiveNavIcon(
|
||||||
|
glyph: item.icon,
|
||||||
|
isDark: isDark,
|
||||||
|
iconSize: _AppBottomNavBarState._inactiveIconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InactiveNavIcon extends StatelessWidget {
|
||||||
|
const _InactiveNavIcon({
|
||||||
|
required this.glyph,
|
||||||
|
required this.isDark,
|
||||||
|
required this.iconSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppIconGlyph glyph;
|
||||||
|
final bool isDark;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: Center(
|
||||||
|
child: AppIcon(
|
||||||
|
glyph: glyph,
|
||||||
|
size: iconSize,
|
||||||
|
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedLuxuryActiveIcon extends StatelessWidget {
|
||||||
|
const _AnimatedLuxuryActiveIcon({
|
||||||
|
required this.animation,
|
||||||
|
required this.icon,
|
||||||
|
required this.isDark,
|
||||||
|
required this.reducedMotion,
|
||||||
|
required this.iconSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Animation<double> animation;
|
||||||
|
final AppIconGlyph icon;
|
||||||
|
final bool isDark;
|
||||||
|
final bool reducedMotion;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (_, __) {
|
||||||
|
return _LuxuryActiveIcon(
|
||||||
|
icon: icon,
|
||||||
|
isDark: isDark,
|
||||||
|
reducedMotion: reducedMotion,
|
||||||
|
progress: reducedMotion ? 0.17 : animation.value,
|
||||||
|
iconSize: iconSize,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LuxuryActiveIcon extends StatelessWidget {
|
||||||
|
const _LuxuryActiveIcon({
|
||||||
|
required this.icon,
|
||||||
|
required this.isDark,
|
||||||
|
required this.reducedMotion,
|
||||||
|
required this.progress,
|
||||||
|
required this.iconSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppIconGlyph icon;
|
||||||
|
final bool isDark;
|
||||||
|
final bool reducedMotion;
|
||||||
|
final double progress;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final baseShadow = isDark
|
||||||
|
? <BoxShadow>[
|
||||||
|
const BoxShadow(
|
||||||
|
color: Color(0xB3000000),
|
||||||
|
blurRadius: 9,
|
||||||
|
spreadRadius: 0.2,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
const BoxShadow(
|
||||||
|
color: AppColors.navGlowDark,
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: -0.8,
|
||||||
|
offset: Offset(0, 1.5),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: <BoxShadow>[
|
||||||
|
const BoxShadow(
|
||||||
|
color: AppColors.navEmbossHighlight,
|
||||||
|
blurRadius: 3.2,
|
||||||
|
offset: Offset(-1.1, -1.1),
|
||||||
|
),
|
||||||
|
const BoxShadow(
|
||||||
|
color: AppColors.navEmbossShadow,
|
||||||
|
blurRadius: 7,
|
||||||
|
offset: Offset(2.3, 3.1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5),
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A),
|
||||||
|
width: 0.85,
|
||||||
|
),
|
||||||
|
boxShadow: baseShadow,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2.0),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF1A202A)
|
||||||
|
: AppColors.navActiveSurfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(12.5),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A),
|
||||||
|
width: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _LuxuryRingPainter(
|
||||||
|
progress: progress,
|
||||||
|
reducedMotion: reducedMotion,
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: AppIcon(
|
||||||
|
glyph: icon,
|
||||||
|
size: iconSize,
|
||||||
|
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textPrimaryDark
|
||||||
|
: AppColors.textPrimaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LuxuryRingPainter extends CustomPainter {
|
||||||
|
const _LuxuryRingPainter({
|
||||||
|
required this.progress,
|
||||||
|
required this.reducedMotion,
|
||||||
|
required this.isDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double progress;
|
||||||
|
final bool reducedMotion;
|
||||||
|
final bool isDark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final rect = Offset.zero & size;
|
||||||
|
final outerRect = rect.deflate(1.05);
|
||||||
|
final ringRect = rect.deflate(2.5);
|
||||||
|
final innerRect = rect.deflate(4.75);
|
||||||
|
|
||||||
|
final outerRRect =
|
||||||
|
RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4));
|
||||||
|
final ringRRect =
|
||||||
|
RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8));
|
||||||
|
final innerRRect =
|
||||||
|
RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1));
|
||||||
|
final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2;
|
||||||
|
|
||||||
|
void drawEmboss(
|
||||||
|
RRect target, {
|
||||||
|
required Offset shadowOffset,
|
||||||
|
required Offset highlightOffset,
|
||||||
|
required Color shadowColor,
|
||||||
|
required Color highlightColor,
|
||||||
|
required double shadowBlur,
|
||||||
|
required double highlightBlur,
|
||||||
|
required double strokeWidth,
|
||||||
|
}) {
|
||||||
|
final shadowPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth
|
||||||
|
..color = shadowColor
|
||||||
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
|
||||||
|
|
||||||
|
final highlightPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth
|
||||||
|
..color = highlightColor
|
||||||
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(shadowOffset.dx, shadowOffset.dy);
|
||||||
|
canvas.drawRRect(target, shadowPaint);
|
||||||
|
canvas.restore();
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(highlightOffset.dx, highlightOffset.dy);
|
||||||
|
canvas.drawRRect(target, highlightPaint);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawEmboss(
|
||||||
|
outerRRect,
|
||||||
|
shadowOffset: const Offset(0.7, 1.0),
|
||||||
|
highlightOffset: const Offset(-0.6, -0.7),
|
||||||
|
shadowColor: isDark
|
||||||
|
? const Color(0xD6000000)
|
||||||
|
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
|
||||||
|
highlightColor: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.22)
|
||||||
|
: Colors.white.withValues(alpha: 0.92),
|
||||||
|
shadowBlur: isDark ? 2.5 : 1.8,
|
||||||
|
highlightBlur: isDark ? 1.6 : 1.1,
|
||||||
|
strokeWidth: 1.05,
|
||||||
|
);
|
||||||
|
|
||||||
|
drawEmboss(
|
||||||
|
innerRRect,
|
||||||
|
shadowOffset: const Offset(-0.45, -0.35),
|
||||||
|
highlightOffset: const Offset(0.45, 0.55),
|
||||||
|
shadowColor: isDark
|
||||||
|
? Colors.black.withValues(alpha: 0.58)
|
||||||
|
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
|
||||||
|
highlightColor: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.14)
|
||||||
|
: Colors.white.withValues(alpha: 0.78),
|
||||||
|
shadowBlur: isDark ? 1.5 : 1.2,
|
||||||
|
highlightBlur: isDark ? 1.1 : 0.9,
|
||||||
|
strokeWidth: 0.88,
|
||||||
|
);
|
||||||
|
|
||||||
|
final metallicRing = SweepGradient(
|
||||||
|
startAngle: rotation,
|
||||||
|
endAngle: rotation + math.pi * 2,
|
||||||
|
colors: const [
|
||||||
|
AppColors.navActiveGoldDeep,
|
||||||
|
AppColors.navActiveGold,
|
||||||
|
AppColors.navActiveGoldBright,
|
||||||
|
AppColors.navActiveGoldPale,
|
||||||
|
AppColors.navActiveGoldBright,
|
||||||
|
AppColors.navActiveGoldDeep,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
final metallicPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.9
|
||||||
|
..shader = metallicRing.createShader(ringRect)
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
final chromaStrength = isDark ? 0.92 : 0.74;
|
||||||
|
final chromaSweep = SweepGradient(
|
||||||
|
startAngle: (rotation * 1.3) + 0.42,
|
||||||
|
endAngle: (rotation * 1.3) + 0.42 + math.pi * 2,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.transparent,
|
||||||
|
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
|
||||||
|
Colors.white.withValues(alpha: 0.94 * chromaStrength),
|
||||||
|
AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength),
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.transparent,
|
||||||
|
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
|
||||||
|
Colors.white.withValues(alpha: 0.88 * chromaStrength),
|
||||||
|
AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength),
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
stops: const [
|
||||||
|
0.0,
|
||||||
|
0.09,
|
||||||
|
0.112,
|
||||||
|
0.126,
|
||||||
|
0.14,
|
||||||
|
0.175,
|
||||||
|
0.45,
|
||||||
|
0.468,
|
||||||
|
0.484,
|
||||||
|
0.5,
|
||||||
|
0.528,
|
||||||
|
1.0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final chromaPaint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.15
|
||||||
|
..shader = chromaSweep.createShader(ringRect)
|
||||||
|
..blendMode = BlendMode.screen;
|
||||||
|
|
||||||
|
canvas.drawRRect(ringRRect, metallicPaint);
|
||||||
|
canvas.drawRRect(ringRRect, chromaPaint);
|
||||||
|
|
||||||
|
final ambientGold = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = isDark ? 1.0 : 0.85
|
||||||
|
..color = isDark
|
||||||
|
? AppColors.navActiveGold.withValues(alpha: 0.18)
|
||||||
|
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
|
||||||
|
..maskFilter = MaskFilter.blur(
|
||||||
|
BlurStyle.normal,
|
||||||
|
isDark ? 2.6 : 1.2,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(ringRRect, ambientGold);
|
||||||
|
|
||||||
|
final innerEdge = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 0.8
|
||||||
|
..color = isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.12)
|
||||||
|
: const Color(0x330F172A);
|
||||||
|
canvas.drawRRect(innerRRect, innerEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) {
|
||||||
|
return oldDelegate.progress != progress ||
|
||||||
|
oldDelegate.reducedMotion != reducedMotion ||
|
||||||
|
oldDelegate.isDark != isDark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
166
lib/core/widgets/notification_bell_button.dart
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
import '../../app/icons/app_icons.dart';
|
||||||
|
import '../../app/theme/app_colors.dart';
|
||||||
|
import '../../data/local/hive_boxes.dart';
|
||||||
|
import '../../data/local/models/app_settings.dart';
|
||||||
|
import '../../data/services/notification_service.dart';
|
||||||
|
import '../../data/services/notification_inbox_service.dart';
|
||||||
|
import '../../features/dashboard/data/prayer_times_provider.dart';
|
||||||
|
|
||||||
|
class NotificationBellButton extends StatelessWidget {
|
||||||
|
const NotificationBellButton({
|
||||||
|
super.key,
|
||||||
|
this.iconColor,
|
||||||
|
this.iconSize = 22,
|
||||||
|
this.onPressed,
|
||||||
|
this.showBadge = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color? iconColor;
|
||||||
|
final double iconSize;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool showBadge;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final inbox = NotificationInboxService.instance;
|
||||||
|
return ValueListenableBuilder(
|
||||||
|
valueListenable: inbox.listenable(),
|
||||||
|
builder: (context, _, __) {
|
||||||
|
final unread = showBadge ? inbox.unreadCount() : 0;
|
||||||
|
return IconButton(
|
||||||
|
onPressed: onPressed ??
|
||||||
|
() {
|
||||||
|
context.push('/notifications');
|
||||||
|
},
|
||||||
|
onLongPress: () => _showQuickActions(context),
|
||||||
|
icon: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
AppIcon(
|
||||||
|
glyph: AppIcons.notification,
|
||||||
|
color: iconColor,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
if (unread > 0)
|
||||||
|
Positioned(
|
||||||
|
right: -6,
|
||||||
|
top: -4,
|
||||||
|
child: Container(
|
||||||
|
constraints:
|
||||||
|
const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
width: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
unread > 99 ? '99+' : '$unread',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showQuickActions(BuildContext context) async {
|
||||||
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
final settings = settingsBox.get('default') ?? AppSettings();
|
||||||
|
final alarmsOn = settings.adhanEnabled.values.any((v) => v);
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor:
|
||||||
|
isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark.withValues(alpha: 0.4)
|
||||||
|
: AppColors.textSecondaryLight.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
alarmsOn
|
||||||
|
? Icons.notifications_off_outlined
|
||||||
|
: Icons.notifications_active_outlined,
|
||||||
|
),
|
||||||
|
title: Text(alarmsOn
|
||||||
|
? 'Nonaktifkan Alarm Sholat'
|
||||||
|
: 'Aktifkan Alarm Sholat'),
|
||||||
|
onTap: () async {
|
||||||
|
final container =
|
||||||
|
ProviderScope.containerOf(context, listen: false);
|
||||||
|
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
|
||||||
|
await settings.save();
|
||||||
|
if (alarmsOn) {
|
||||||
|
await NotificationService.instance.cancelAllPending();
|
||||||
|
}
|
||||||
|
container.invalidate(prayerTimesProvider);
|
||||||
|
unawaited(container.read(prayerTimesProvider.future));
|
||||||
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.sync_rounded),
|
||||||
|
title: const Text('Sinkronkan Sekarang'),
|
||||||
|
onTap: () {
|
||||||
|
final container =
|
||||||
|
ProviderScope.containerOf(context, listen: false);
|
||||||
|
container.invalidate(prayerTimesProvider);
|
||||||
|
unawaited(container.read(prayerTimesProvider.future));
|
||||||
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings_outlined),
|
||||||
|
title: const Text('Buka Pengaturan'),
|
||||||
|
onTap: () {
|
||||||
|
if (sheetContext.mounted) Navigator.pop(sheetContext);
|
||||||
|
context.push('/settings');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../app/icons/app_icons.dart';
|
||||||
import '../../app/theme/app_colors.dart';
|
import '../../app/theme/app_colors.dart';
|
||||||
|
|
||||||
class ToolCard extends StatelessWidget {
|
class ToolCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final AppIconGlyph icon;
|
||||||
final String title;
|
final String title;
|
||||||
final Color color;
|
final Color color;
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
@@ -28,9 +29,7 @@ class ToolCard extends StatelessWidget {
|
|||||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isDark
|
color: isDark ? color.withValues(alpha: 0.15) : AppColors.cream,
|
||||||
? color.withValues(alpha: 0.15)
|
|
||||||
: AppColors.cream,
|
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -51,7 +50,14 @@ class ToolCard extends StatelessWidget {
|
|||||||
color: color.withValues(alpha: 0.15),
|
color: color.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
Text(
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class HiveBoxes {
|
|||||||
static const String dzikirCounters = 'dzikir_counters';
|
static const String dzikirCounters = 'dzikir_counters';
|
||||||
static const String bookmarks = 'bookmarks';
|
static const String bookmarks = 'bookmarks';
|
||||||
static const String cachedPrayerTimes = 'cached_prayer_times';
|
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.
|
/// Initialize Hive and open all boxes.
|
||||||
@@ -56,6 +58,8 @@ Future<void> initHive() async {
|
|||||||
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||||
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
||||||
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
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>)
|
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
|
||||||
final keysToDelete = [];
|
final keysToDelete = [];
|
||||||
@@ -89,26 +93,53 @@ Future<void> seedDefaults() async {
|
|||||||
if (checklistBox.isEmpty) {
|
if (checklistBox.isEmpty) {
|
||||||
final defaults = [
|
final defaults = [
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
|
id: 'fajr',
|
||||||
|
title: 'Sholat Fajr',
|
||||||
|
category: 'sholat_fardhu',
|
||||||
|
sortOrder: 0),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
|
id: 'dhuhr',
|
||||||
|
title: 'Sholat Dhuhr',
|
||||||
|
category: 'sholat_fardhu',
|
||||||
|
sortOrder: 1),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
|
id: 'asr',
|
||||||
|
title: 'Sholat Asr',
|
||||||
|
category: 'sholat_fardhu',
|
||||||
|
sortOrder: 2),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
|
id: 'maghrib',
|
||||||
|
title: 'Sholat Maghrib',
|
||||||
|
category: 'sholat_fardhu',
|
||||||
|
sortOrder: 3),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
|
id: 'isha',
|
||||||
|
title: 'Sholat Isha',
|
||||||
|
category: 'sholat_fardhu',
|
||||||
|
sortOrder: 4),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
|
id: 'tilawah',
|
||||||
subtitle: '1 Juz', sortOrder: 5),
|
title: 'Tilawah Quran',
|
||||||
|
category: 'tilawah',
|
||||||
|
subtitle: '1 Juz',
|
||||||
|
sortOrder: 5),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
|
id: 'dzikir_pagi',
|
||||||
subtitle: '1 session', sortOrder: 6),
|
title: 'Dzikir Pagi',
|
||||||
|
category: 'dzikir',
|
||||||
|
subtitle: '1 session',
|
||||||
|
sortOrder: 6),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
|
id: 'dzikir_petang',
|
||||||
subtitle: '1 session', sortOrder: 7),
|
title: 'Dzikir Petang',
|
||||||
|
category: 'dzikir',
|
||||||
|
subtitle: '1 session',
|
||||||
|
sortOrder: 7),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
|
id: 'rawatib',
|
||||||
|
title: 'Sholat Sunnah Rawatib',
|
||||||
|
category: 'sunnah',
|
||||||
|
sortOrder: 8),
|
||||||
ChecklistItem(
|
ChecklistItem(
|
||||||
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -65,6 +65,45 @@ class AppSettings extends HiveObject {
|
|||||||
@HiveField(19)
|
@HiveField(19)
|
||||||
bool simpleMode; // false = Mode Lengkap, true = Mode Simpel
|
bool simpleMode; // false = Mode Lengkap, true = Mode Simpel
|
||||||
|
|
||||||
|
@HiveField(20)
|
||||||
|
String dzikirDisplayMode; // 'list' | 'focus'
|
||||||
|
|
||||||
|
@HiveField(21)
|
||||||
|
String dzikirCounterButtonPosition; // 'bottomPill' | 'fabCircle'
|
||||||
|
|
||||||
|
@HiveField(22)
|
||||||
|
bool dzikirAutoAdvance;
|
||||||
|
|
||||||
|
@HiveField(23)
|
||||||
|
bool dzikirHapticOnCount;
|
||||||
|
|
||||||
|
@HiveField(24)
|
||||||
|
bool alertsEnabled;
|
||||||
|
|
||||||
|
@HiveField(25)
|
||||||
|
bool inboxEnabled;
|
||||||
|
|
||||||
|
@HiveField(26)
|
||||||
|
bool streakRiskEnabled;
|
||||||
|
|
||||||
|
@HiveField(27)
|
||||||
|
bool dailyChecklistReminderEnabled;
|
||||||
|
|
||||||
|
@HiveField(28)
|
||||||
|
bool weeklySummaryEnabled;
|
||||||
|
|
||||||
|
@HiveField(29)
|
||||||
|
String quietHoursStart; // HH:mm
|
||||||
|
|
||||||
|
@HiveField(30)
|
||||||
|
String quietHoursEnd; // HH:mm
|
||||||
|
|
||||||
|
@HiveField(31)
|
||||||
|
int maxNonPrayerPushPerDay;
|
||||||
|
|
||||||
|
@HiveField(32)
|
||||||
|
bool mirrorAdzanToInbox;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
this.userName = 'User',
|
this.userName = 'User',
|
||||||
this.userEmail = '',
|
this.userEmail = '',
|
||||||
@@ -86,6 +125,19 @@ class AppSettings extends HiveObject {
|
|||||||
this.showLatin = true,
|
this.showLatin = true,
|
||||||
this.showTerjemahan = true,
|
this.showTerjemahan = true,
|
||||||
this.simpleMode = false,
|
this.simpleMode = false,
|
||||||
|
this.dzikirDisplayMode = 'list',
|
||||||
|
this.dzikirCounterButtonPosition = 'bottomPill',
|
||||||
|
this.dzikirAutoAdvance = true,
|
||||||
|
this.dzikirHapticOnCount = true,
|
||||||
|
this.alertsEnabled = true,
|
||||||
|
this.inboxEnabled = true,
|
||||||
|
this.streakRiskEnabled = true,
|
||||||
|
this.dailyChecklistReminderEnabled = false,
|
||||||
|
this.weeklySummaryEnabled = true,
|
||||||
|
this.quietHoursStart = '22:00',
|
||||||
|
this.quietHoursEnd = '05:00',
|
||||||
|
this.maxNonPrayerPushPerDay = 2,
|
||||||
|
this.mirrorAdzanToInbox = false,
|
||||||
}) : adhanEnabled = adhanEnabled ??
|
}) : adhanEnabled = adhanEnabled ??
|
||||||
{
|
{
|
||||||
'fajr': true,
|
'fajr': true,
|
||||||
|
|||||||
@@ -20,30 +20,65 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
|||||||
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
|
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
|
||||||
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
|
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
|
||||||
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
|
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',
|
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
|
||||||
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
|
adhanEnabled: fields.containsKey(5)
|
||||||
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
|
? (fields[5] as Map?)?.cast<String, bool>()
|
||||||
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
|
: 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,
|
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
|
||||||
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
|
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
|
||||||
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
|
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
|
||||||
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
|
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
|
||||||
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
|
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
|
||||||
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
tilawahTargetUnit:
|
||||||
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
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,
|
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
|
||||||
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
|
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
|
||||||
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
|
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,
|
simpleMode: fields.containsKey(19) ? fields[19] as bool? ?? false : false,
|
||||||
|
dzikirDisplayMode:
|
||||||
|
fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
|
||||||
|
dzikirCounterButtonPosition: fields.containsKey(21)
|
||||||
|
? fields[21] as String? ?? 'bottomPill'
|
||||||
|
: 'bottomPill',
|
||||||
|
dzikirAutoAdvance:
|
||||||
|
fields.containsKey(22) ? fields[22] as bool? ?? true : true,
|
||||||
|
dzikirHapticOnCount:
|
||||||
|
fields.containsKey(23) ? fields[23] as bool? ?? true : true,
|
||||||
|
alertsEnabled:
|
||||||
|
fields.containsKey(24) ? fields[24] as bool? ?? true : true,
|
||||||
|
inboxEnabled: fields.containsKey(25) ? fields[25] as bool? ?? true : true,
|
||||||
|
streakRiskEnabled:
|
||||||
|
fields.containsKey(26) ? fields[26] as bool? ?? true : true,
|
||||||
|
dailyChecklistReminderEnabled:
|
||||||
|
fields.containsKey(27) ? fields[27] as bool? ?? false : false,
|
||||||
|
weeklySummaryEnabled:
|
||||||
|
fields.containsKey(28) ? fields[28] as bool? ?? true : true,
|
||||||
|
quietHoursStart:
|
||||||
|
fields.containsKey(29) ? fields[29] as String? ?? '22:00' : '22:00',
|
||||||
|
quietHoursEnd:
|
||||||
|
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
|
||||||
|
maxNonPrayerPushPerDay:
|
||||||
|
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
|
||||||
|
mirrorAdzanToInbox:
|
||||||
|
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, AppSettings obj) {
|
void write(BinaryWriter writer, AppSettings obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(20)
|
..writeByte(33)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.userName)
|
..write(obj.userName)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -83,7 +118,33 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
|||||||
..writeByte(18)
|
..writeByte(18)
|
||||||
..write(obj.showTerjemahan)
|
..write(obj.showTerjemahan)
|
||||||
..writeByte(19)
|
..writeByte(19)
|
||||||
..write(obj.simpleMode);
|
..write(obj.simpleMode)
|
||||||
|
..writeByte(20)
|
||||||
|
..write(obj.dzikirDisplayMode)
|
||||||
|
..writeByte(21)
|
||||||
|
..write(obj.dzikirCounterButtonPosition)
|
||||||
|
..writeByte(22)
|
||||||
|
..write(obj.dzikirAutoAdvance)
|
||||||
|
..writeByte(23)
|
||||||
|
..write(obj.dzikirHapticOnCount)
|
||||||
|
..writeByte(24)
|
||||||
|
..write(obj.alertsEnabled)
|
||||||
|
..writeByte(25)
|
||||||
|
..write(obj.inboxEnabled)
|
||||||
|
..writeByte(26)
|
||||||
|
..write(obj.streakRiskEnabled)
|
||||||
|
..writeByte(27)
|
||||||
|
..write(obj.dailyChecklistReminderEnabled)
|
||||||
|
..writeByte(28)
|
||||||
|
..write(obj.weeklySummaryEnabled)
|
||||||
|
..writeByte(29)
|
||||||
|
..write(obj.quietHoursStart)
|
||||||
|
..writeByte(30)
|
||||||
|
..write(obj.quietHoursEnd)
|
||||||
|
..writeByte(31)
|
||||||
|
..write(obj.maxNonPrayerPushPerDay)
|
||||||
|
..writeByte(32)
|
||||||
|
..write(obj.mirrorAdzanToInbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
657
lib/data/services/muslim_api_service.dart
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class MuslimApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const MuslimApiException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MuslimApiException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for muslim.backoffice.biz.id API.
|
||||||
|
///
|
||||||
|
/// Exposes Quran, dzikir, doa, hadits, and enrichment data while preserving
|
||||||
|
/// the data contract currently expected by Quran and dashboard UI widgets.
|
||||||
|
class MuslimApiService {
|
||||||
|
static const String _baseUrl = 'https://muslim.backoffice.biz.id';
|
||||||
|
static final MuslimApiService instance = MuslimApiService._();
|
||||||
|
|
||||||
|
MuslimApiService._();
|
||||||
|
|
||||||
|
static const Map<String, String> qariNames = {
|
||||||
|
'01': 'Abdullah Al-Juhany',
|
||||||
|
'02': 'Abdul Muhsin Al-Qasim',
|
||||||
|
'03': 'Abdurrahman As-Sudais',
|
||||||
|
'04': 'Ibrahim Al-Dossari',
|
||||||
|
'05': 'Misyari Rasyid Al-Afasi',
|
||||||
|
'06': 'Yasser Al-Dosari',
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Map<String, dynamic>>? _surahListCache;
|
||||||
|
final Map<int, Map<String, dynamic>> _surahCache = {};
|
||||||
|
|
||||||
|
List<Map<String, dynamic>>? _allAyahCache;
|
||||||
|
List<Map<String, dynamic>>? _tafsirCache;
|
||||||
|
List<Map<String, dynamic>>? _asbabCache;
|
||||||
|
List<Map<String, dynamic>>? _juzCache;
|
||||||
|
List<Map<String, dynamic>>? _themeCache;
|
||||||
|
List<Map<String, dynamic>>? _asmaCache;
|
||||||
|
List<Map<String, dynamic>>? _doaCache;
|
||||||
|
List<Map<String, dynamic>>? _haditsCache;
|
||||||
|
|
||||||
|
final Map<String, List<Map<String, dynamic>>> _dzikirByTypeCache = {};
|
||||||
|
final Map<String, List<Map<String, dynamic>>> _wordByWordCache = {};
|
||||||
|
final Map<int, List<Map<String, dynamic>>> _pageAyahCache = {};
|
||||||
|
|
||||||
|
Future<dynamic> _getData(String path) async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse('$_baseUrl$path'));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final decoded = json.decode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
return decoded['data'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getDataOrThrow(String path) async {
|
||||||
|
final response = await http.get(Uri.parse('$_baseUrl$path'));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw MuslimApiException(
|
||||||
|
'Request failed ($path): HTTP ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = json.decode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const MuslimApiException('Invalid API payload shape');
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = _asInt(decoded['status']);
|
||||||
|
if (status != 200) {
|
||||||
|
throw MuslimApiException('API returned non-200 status: $status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decoded.containsKey('data')) {
|
||||||
|
throw const MuslimApiException('API payload missing data key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
int _asInt(dynamic value, {int fallback = 0}) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value) ?? fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _asString(dynamic value, {String fallback = ''}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _asCount(dynamic value, {int fallback = 1}) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
final text = value.toString();
|
||||||
|
final match = RegExp(r'\d+').firstMatch(text);
|
||||||
|
if (match == null) return fallback;
|
||||||
|
return int.tryParse(match.group(0)!) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stableDzikirId(String type, Map<String, dynamic> item) {
|
||||||
|
final apiId = _asString(item['id']);
|
||||||
|
if (apiId.isNotEmpty) {
|
||||||
|
return '${type}_$apiId';
|
||||||
|
}
|
||||||
|
|
||||||
|
final seed = [
|
||||||
|
type,
|
||||||
|
_asString(item['type']),
|
||||||
|
_asString(item['arab']),
|
||||||
|
_asString(item['indo']),
|
||||||
|
_asString(item['ulang']),
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
var hash = 0;
|
||||||
|
for (final unit in seed.codeUnits) {
|
||||||
|
hash = ((hash * 31) + unit) & 0x7fffffff;
|
||||||
|
}
|
||||||
|
return '${type}_$hash';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dzikirApiType(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'petang':
|
||||||
|
return 'sore';
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
|
||||||
|
final audioUrl = _extractAudioUrl(audioValue);
|
||||||
|
if (audioUrl.isEmpty) return {};
|
||||||
|
return {
|
||||||
|
'01': audioUrl,
|
||||||
|
'02': audioUrl,
|
||||||
|
'03': audioUrl,
|
||||||
|
'04': audioUrl,
|
||||||
|
'05': audioUrl,
|
||||||
|
'06': audioUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractAudioUrl(dynamic value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value is String) return value.trim();
|
||||||
|
if (value is Map) {
|
||||||
|
final direct = _asString(value['url']).trim();
|
||||||
|
if (direct.isNotEmpty) return direct;
|
||||||
|
final src = _asString(value['src']).trim();
|
||||||
|
if (src.isNotEmpty) return src;
|
||||||
|
final audio = _asString(value['audio']).trim();
|
||||||
|
if (audio.isNotEmpty) return audio;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeQariKey(dynamic rawKey) {
|
||||||
|
if (rawKey == null) return '';
|
||||||
|
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
|
||||||
|
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
final text = rawKey.toString().trim();
|
||||||
|
if (text.isEmpty) return '';
|
||||||
|
|
||||||
|
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
|
||||||
|
if (digits.isNotEmpty) {
|
||||||
|
final parsed = int.tryParse(digits);
|
||||||
|
if (parsed != null) return parsed.toString().padLeft(2, '0');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
|
||||||
|
if (audioValue is Map) {
|
||||||
|
final normalized = <String, String>{};
|
||||||
|
audioValue.forEach((rawKey, rawValue) {
|
||||||
|
final key = _normalizeQariKey(rawKey);
|
||||||
|
final url = _extractAudioUrl(rawValue);
|
||||||
|
if (key.isNotEmpty && url.isNotEmpty) {
|
||||||
|
normalized[key] = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalized.isNotEmpty) {
|
||||||
|
final fallbackUrl = normalized.values.first;
|
||||||
|
for (final qariId in qariNames.keys) {
|
||||||
|
normalized.putIfAbsent(qariId, () => fallbackUrl);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _normalizeAudioMap(audioValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
|
||||||
|
final number = _asInt(item['number']);
|
||||||
|
return {
|
||||||
|
'nomor': number,
|
||||||
|
'nama': _asString(item['name_short']),
|
||||||
|
'namaLatin': _asString(item['name_id']),
|
||||||
|
'jumlahAyat': _asInt(item['number_of_verses']),
|
||||||
|
'tempatTurun': _asString(item['revelation_id']),
|
||||||
|
'arti': _asString(item['translation_id']),
|
||||||
|
'deskripsi': _asString(item['tafsir']),
|
||||||
|
'audioFull': _normalizeAudioMap(item['audio_url']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
|
||||||
|
return {
|
||||||
|
'nomorAyat': _asInt(item['ayah']),
|
||||||
|
'teksArab': _asString(item['arab']),
|
||||||
|
'teksLatin': _asString(item['latin']),
|
||||||
|
'teksIndonesia': _asString(item['text']),
|
||||||
|
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
|
||||||
|
'juz': _asInt(item['juz']),
|
||||||
|
'page': _asInt(item['page']),
|
||||||
|
'hizb': _asInt(item['hizb']),
|
||||||
|
'theme': _asString(item['theme']),
|
||||||
|
'asbab': _asString(item['asbab']),
|
||||||
|
'notes': _asString(item['notes']),
|
||||||
|
'surah': _asInt(item['surah']),
|
||||||
|
'ayahId': _asInt(item['id']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getAllSurahs() async {
|
||||||
|
if (_surahListCache != null) return _surahListCache!;
|
||||||
|
final raw = await _getData('/v1/quran/surah');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
_surahListCache =
|
||||||
|
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
|
||||||
|
return _surahListCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getSurah(int number) async {
|
||||||
|
if (_surahCache.containsKey(number)) {
|
||||||
|
return _surahCache[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
final surahs = await getAllSurahs();
|
||||||
|
Map<String, dynamic>? summary;
|
||||||
|
for (final surah in surahs) {
|
||||||
|
if (surah['nomor'] == number) {
|
||||||
|
summary = surah;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final rawAyah = await _getData('/v1/quran/ayah/surah?id=$number');
|
||||||
|
if (summary == null || rawAyah is! List) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mappedAyah =
|
||||||
|
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
|
||||||
|
|
||||||
|
final mapped = {
|
||||||
|
...summary,
|
||||||
|
'ayat': mappedAyah,
|
||||||
|
};
|
||||||
|
_surahCache[number] = mapped;
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getDailyAyat() async {
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dayOfYear = now.difference(DateTime(now.year, 1, 1)).inDays;
|
||||||
|
final surahId = (dayOfYear % 114) + 1;
|
||||||
|
final surah = await getSurah(surahId);
|
||||||
|
if (surah == null) return null;
|
||||||
|
|
||||||
|
final ayat = List<Map<String, dynamic>>.from(surah['ayat'] ?? []);
|
||||||
|
if (ayat.isEmpty) return null;
|
||||||
|
|
||||||
|
final ayatIndex = dayOfYear % ayat.length;
|
||||||
|
final picked = ayat[ayatIndex];
|
||||||
|
return {
|
||||||
|
'surahName': surah['namaLatin'] ?? '',
|
||||||
|
'nomorSurah': surahId,
|
||||||
|
'nomorAyat': picked['nomorAyat'] ?? 1,
|
||||||
|
'teksArab': picked['teksArab'] ?? '',
|
||||||
|
'teksIndonesia': picked['teksIndonesia'] ?? '',
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getRandomAyat({
|
||||||
|
int? excludeSurahNumber,
|
||||||
|
int? excludeAyahNumber,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final allAyah = await getAllAyah();
|
||||||
|
if (allAyah.isEmpty) return null;
|
||||||
|
|
||||||
|
final surahs = await getAllSurahs();
|
||||||
|
if (surahs.isEmpty) return null;
|
||||||
|
|
||||||
|
final surahNames = <int, String>{
|
||||||
|
for (final surah in surahs)
|
||||||
|
_asInt(surah['nomor']): _asString(surah['namaLatin']),
|
||||||
|
};
|
||||||
|
|
||||||
|
final filtered = allAyah.where((ayah) {
|
||||||
|
final surahNumber = _asInt(ayah['surah']);
|
||||||
|
final ayahNumber = _asInt(ayah['ayah']);
|
||||||
|
final isExcluded = excludeSurahNumber != null &&
|
||||||
|
excludeAyahNumber != null &&
|
||||||
|
surahNumber == excludeSurahNumber &&
|
||||||
|
ayahNumber == excludeAyahNumber;
|
||||||
|
if (isExcluded) return false;
|
||||||
|
|
||||||
|
return _asString(ayah['arab']).trim().isNotEmpty &&
|
||||||
|
_asString(ayah['text']).trim().isNotEmpty;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final candidates = filtered.isNotEmpty ? filtered : allAyah;
|
||||||
|
final picked = candidates[Random().nextInt(candidates.length)];
|
||||||
|
final surahNumber = _asInt(picked['surah']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'surahName': surahNames[surahNumber] ?? '',
|
||||||
|
'nomorSurah': surahNumber,
|
||||||
|
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
|
||||||
|
'teksArab': _asString(picked['arab']),
|
||||||
|
'teksIndonesia': _asString(picked['text']),
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getWordByWord(
|
||||||
|
int surahId, int ayahId) async {
|
||||||
|
final key = '$surahId:$ayahId';
|
||||||
|
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
|
||||||
|
|
||||||
|
final raw =
|
||||||
|
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'word': _asString(item['word']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'indo': _asString(item['indo']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_wordByWordCache[key] = mapped;
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getAllAyah() async {
|
||||||
|
if (_allAyahCache != null) return _allAyahCache!;
|
||||||
|
final raw = await _getData('/v1/quran/ayah');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
_allAyahCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'id': _asInt(item['id']),
|
||||||
|
'surah': _asInt(item['surah']),
|
||||||
|
'ayah': _asInt(item['ayah']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'latin': _asString(item['latin']),
|
||||||
|
'text': _asString(item['text']),
|
||||||
|
'juz': _asInt(item['juz']),
|
||||||
|
'page': _asInt(item['page']),
|
||||||
|
'hizb': _asInt(item['hizb']),
|
||||||
|
'theme': _asString(item['theme']),
|
||||||
|
'asbab': _asString(item['asbab']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _allAyahCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getTafsirBySurah(int surahId) async {
|
||||||
|
if (_tafsirCache == null) {
|
||||||
|
final raw = await _getData('/v1/quran/tafsir');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
_tafsirCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'id': _asInt(item['id']),
|
||||||
|
'ayah': _asInt(item['ayah']),
|
||||||
|
'wajiz': _asString(item['wajiz']),
|
||||||
|
'tahlili': _asString(item['tahlili']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final allAyah = await getAllAyah();
|
||||||
|
if (allAyah.isEmpty || _tafsirCache == null) return [];
|
||||||
|
|
||||||
|
final ayahById = <int, Map<String, dynamic>>{};
|
||||||
|
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
|
||||||
|
for (final ayah in allAyah) {
|
||||||
|
final id = _asInt(ayah['id']);
|
||||||
|
final surah = _asInt(ayah['surah']);
|
||||||
|
final ayahNumber = _asInt(ayah['ayah']);
|
||||||
|
ayahById[id] = ayah;
|
||||||
|
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <Map<String, dynamic>>[];
|
||||||
|
for (final tafsir in _tafsirCache!) {
|
||||||
|
final tafsirId = _asInt(tafsir['id']);
|
||||||
|
final tafsirAyah = _asInt(tafsir['ayah']);
|
||||||
|
Map<String, dynamic>? ayahMeta = ayahById[tafsirId];
|
||||||
|
ayahMeta ??= ayahBySurahAyah['$surahId:$tafsirAyah'];
|
||||||
|
if (ayahMeta == null) continue;
|
||||||
|
if (ayahMeta['surah'] != surahId) continue;
|
||||||
|
result.add({
|
||||||
|
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: tafsirAyah),
|
||||||
|
'wajiz': tafsir['wajiz'],
|
||||||
|
'tahlili': tafsir['tahlili'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort(
|
||||||
|
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getAsbabBySurah(int surahId) async {
|
||||||
|
if (_asbabCache == null) {
|
||||||
|
final raw = await _getData('/v1/quran/asbab');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
_asbabCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'id': _asInt(item['id']),
|
||||||
|
'ayah': _asInt(item['ayah']),
|
||||||
|
'text': _asString(item['text']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final allAyah = await getAllAyah();
|
||||||
|
if (allAyah.isEmpty || _asbabCache == null) return [];
|
||||||
|
|
||||||
|
final ayahById = <int, Map<String, dynamic>>{};
|
||||||
|
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
|
||||||
|
for (final ayah in allAyah) {
|
||||||
|
final id = _asInt(ayah['id']);
|
||||||
|
final surah = _asInt(ayah['surah']);
|
||||||
|
final ayahNumber = _asInt(ayah['ayah']);
|
||||||
|
ayahById[id] = ayah;
|
||||||
|
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <Map<String, dynamic>>[];
|
||||||
|
for (final asbab in _asbabCache!) {
|
||||||
|
final asbabId = _asInt(asbab['id']);
|
||||||
|
final asbabAyah = _asInt(asbab['ayah']);
|
||||||
|
Map<String, dynamic>? ayahMeta = ayahById[asbabId];
|
||||||
|
ayahMeta ??= ayahBySurahAyah['$surahId:$asbabAyah'];
|
||||||
|
if (ayahMeta == null) continue;
|
||||||
|
if (ayahMeta['surah'] != surahId) continue;
|
||||||
|
result.add({
|
||||||
|
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: asbabAyah),
|
||||||
|
'text': asbab['text'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort(
|
||||||
|
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getJuzList() async {
|
||||||
|
if (_juzCache != null) return _juzCache!;
|
||||||
|
final raw = await _getData('/v1/quran/juz');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
_juzCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'number': _asInt(item['number']),
|
||||||
|
'name': _asString(item['name']),
|
||||||
|
'surah_id_start': _asInt(item['surah_id_start']),
|
||||||
|
'verse_start': _asInt(item['verse_start']),
|
||||||
|
'surah_id_end': _asInt(item['surah_id_end']),
|
||||||
|
'verse_end': _asInt(item['verse_end']),
|
||||||
|
'name_start_id': _asString(item['name_start_id']),
|
||||||
|
'name_end_id': _asString(item['name_end_id']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _juzCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getAyahByPage(int page) async {
|
||||||
|
if (_pageAyahCache.containsKey(page)) return _pageAyahCache[page]!;
|
||||||
|
final raw = await _getData('/v1/quran/ayah/page?id=$page');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'surah': _asInt(item['surah']),
|
||||||
|
'ayah': _asInt(item['ayah']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'text': _asString(item['text']),
|
||||||
|
'theme': _asString(item['theme']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_pageAyahCache[page] = mapped;
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getThemes() async {
|
||||||
|
if (_themeCache != null) return _themeCache!;
|
||||||
|
final raw = await _getData('/v1/quran/theme');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
_themeCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'id': _asInt(item['id']),
|
||||||
|
'name': _asString(item['name']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
return _themeCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> searchAyah(String query) async {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return [];
|
||||||
|
|
||||||
|
final allAyah = await getAllAyah();
|
||||||
|
final results = allAyah
|
||||||
|
.where((item) {
|
||||||
|
final text = _asString(item['text']).toLowerCase();
|
||||||
|
final latin = _asString(item['latin']).toLowerCase();
|
||||||
|
final arab = _asString(item['arab']);
|
||||||
|
return text.contains(q) ||
|
||||||
|
latin.contains(q) ||
|
||||||
|
arab.contains(query.trim());
|
||||||
|
})
|
||||||
|
.take(50)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getAsmaulHusna() async {
|
||||||
|
if (_asmaCache != null) return _asmaCache!;
|
||||||
|
final raw = await _getData('/v1/quran/asma');
|
||||||
|
if (raw is! List) return [];
|
||||||
|
|
||||||
|
_asmaCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'id': _asInt(item['id']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'latin': _asString(item['latin']),
|
||||||
|
'indo': _asString(item['indo']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _asmaCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
|
||||||
|
if (_doaCache != null) return _doaCache!;
|
||||||
|
final raw =
|
||||||
|
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
|
||||||
|
if (raw is! List) {
|
||||||
|
if (strict) {
|
||||||
|
throw const MuslimApiException('Invalid doa payload');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_doaCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'judul': _asString(item['judul']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'indo': _asString(item['indo']),
|
||||||
|
'source': _asString(item['source']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _doaCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getHaditsList(
|
||||||
|
{bool strict = false}) async {
|
||||||
|
if (_haditsCache != null) return _haditsCache!;
|
||||||
|
final raw = strict
|
||||||
|
? await _getDataOrThrow('/v1/hadits')
|
||||||
|
: await _getData('/v1/hadits');
|
||||||
|
if (raw is! List) {
|
||||||
|
if (strict) {
|
||||||
|
throw const MuslimApiException('Invalid hadits payload');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_haditsCache = raw.whereType<Map<String, dynamic>>().map((item) {
|
||||||
|
return {
|
||||||
|
'no': _asInt(item['no']),
|
||||||
|
'judul': _asString(item['judul']),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'indo': _asString(item['indo']),
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return _haditsCache!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getDzikirByType(
|
||||||
|
String type, {
|
||||||
|
bool strict = false,
|
||||||
|
}) async {
|
||||||
|
if (_dzikirByTypeCache.containsKey(type)) {
|
||||||
|
return _dzikirByTypeCache[type]!;
|
||||||
|
}
|
||||||
|
final apiType = _dzikirApiType(type);
|
||||||
|
final raw = strict
|
||||||
|
? await _getDataOrThrow('/v1/dzikir?type=$apiType')
|
||||||
|
: await _getData('/v1/dzikir?type=$apiType');
|
||||||
|
if (raw is! List) {
|
||||||
|
if (strict) {
|
||||||
|
throw MuslimApiException('Invalid dzikir payload for type: $type');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final mapped = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < raw.length; i++) {
|
||||||
|
final item = raw[i];
|
||||||
|
if (item is! Map<String, dynamic>) continue;
|
||||||
|
mapped.add({
|
||||||
|
'id': _stableDzikirId(type, item),
|
||||||
|
'arab': _asString(item['arab']),
|
||||||
|
'indo': _asString(item['indo']),
|
||||||
|
'type': _asString(item['type']),
|
||||||
|
'ulang': _asCount(item['ulang'], fallback: 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_dzikirByTypeCache[type] = mapped;
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/data/services/notification_analytics_service.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../local/hive_boxes.dart';
|
||||||
|
|
||||||
|
/// Lightweight local analytics sink for notification events.
|
||||||
|
class NotificationAnalyticsService {
|
||||||
|
NotificationAnalyticsService._();
|
||||||
|
static final NotificationAnalyticsService instance =
|
||||||
|
NotificationAnalyticsService._();
|
||||||
|
|
||||||
|
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||||
|
|
||||||
|
Future<void> track(
|
||||||
|
String event, {
|
||||||
|
Map<String, dynamic> dimensions = const <String, dynamic>{},
|
||||||
|
}) async {
|
||||||
|
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||||
|
final counterKey = 'analytics.$date.$event';
|
||||||
|
final current = (_box.get(counterKey) as int?) ?? 0;
|
||||||
|
await _box.put(counterKey, current + 1);
|
||||||
|
|
||||||
|
// Keep a small rolling audit buffer for debug support.
|
||||||
|
final raw = (_box.get('analytics.recent') ?? '[]').toString();
|
||||||
|
final decoded = json.decode(raw);
|
||||||
|
final list = decoded is List ? decoded : <dynamic>[];
|
||||||
|
list.add({
|
||||||
|
'event': event,
|
||||||
|
'at': DateTime.now().toIso8601String(),
|
||||||
|
'dimensions': dimensions,
|
||||||
|
});
|
||||||
|
while (list.length > 100) {
|
||||||
|
list.removeAt(0);
|
||||||
|
}
|
||||||
|
await _box.put('analytics.recent', json.encode(list));
|
||||||
|
}
|
||||||
|
}
|
||||||
299
lib/data/services/notification_event_producer_service.dart
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../local/hive_boxes.dart';
|
||||||
|
import '../local/models/app_settings.dart';
|
||||||
|
import '../local/models/daily_worship_log.dart';
|
||||||
|
import 'notification_inbox_service.dart';
|
||||||
|
import 'notification_runtime_service.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
|
/// Creates in-app inbox events from runtime/system conditions.
|
||||||
|
class NotificationEventProducerService {
|
||||||
|
NotificationEventProducerService._();
|
||||||
|
static final NotificationEventProducerService instance =
|
||||||
|
NotificationEventProducerService._();
|
||||||
|
|
||||||
|
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||||
|
final NotificationRuntimeService _runtime =
|
||||||
|
NotificationRuntimeService.instance;
|
||||||
|
|
||||||
|
Future<void> emitPermissionWarningsIfNeeded({
|
||||||
|
required AppSettings settings,
|
||||||
|
required NotificationPermissionStatus permissionStatus,
|
||||||
|
}) async {
|
||||||
|
if (!settings.adhanEnabled.values.any((v) => v)) return;
|
||||||
|
|
||||||
|
final dateKey = _todayKey();
|
||||||
|
|
||||||
|
if (!permissionStatus.notificationsAllowed) {
|
||||||
|
final title = 'Izin notifikasi dinonaktifkan';
|
||||||
|
final body =
|
||||||
|
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
|
||||||
|
if (settings.inboxEnabled) {
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'system',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/settings',
|
||||||
|
dedupeKey: 'system.permission.notifications.$dateKey',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _pushSystemIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.system.permission.notifications.$dateKey',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissionStatus.exactAlarmAllowed) {
|
||||||
|
final title = 'Izin alarm presisi belum aktif';
|
||||||
|
final body =
|
||||||
|
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
|
||||||
|
if (settings.inboxEnabled) {
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'system',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/settings',
|
||||||
|
dedupeKey: 'system.permission.exact_alarm.$dateKey',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _pushSystemIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emitScheduleFallback({
|
||||||
|
required AppSettings settings,
|
||||||
|
required String cityId,
|
||||||
|
required bool locationUnavailable,
|
||||||
|
}) async {
|
||||||
|
final dateKey = _todayKey();
|
||||||
|
final title = locationUnavailable
|
||||||
|
? 'Lokasi belum tersedia'
|
||||||
|
: 'Jadwal online terganggu';
|
||||||
|
final body = locationUnavailable
|
||||||
|
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
|
||||||
|
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
|
||||||
|
final scope = locationUnavailable ? 'loc' : 'net';
|
||||||
|
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
|
||||||
|
|
||||||
|
if (settings.inboxEnabled) {
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'system',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/imsakiyah',
|
||||||
|
dedupeKey: dedupe,
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
meta: <String, dynamic>{
|
||||||
|
'cityId': cityId,
|
||||||
|
'date': dateKey,
|
||||||
|
'scope': scope,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _pushSystemIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.$dedupe',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emitNotificationSyncFailed({
|
||||||
|
required AppSettings settings,
|
||||||
|
required String cityId,
|
||||||
|
}) async {
|
||||||
|
final dateKey = _todayKey();
|
||||||
|
final title = 'Sinkronisasi alarm adzan gagal';
|
||||||
|
final body =
|
||||||
|
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
|
||||||
|
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
|
||||||
|
|
||||||
|
if (settings.inboxEnabled) {
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'system',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/settings',
|
||||||
|
dedupeKey: dedupe,
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
meta: <String, dynamic>{
|
||||||
|
'cityId': cityId,
|
||||||
|
'date': dateKey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _pushSystemIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.$dedupe',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emitStreakRiskIfNeeded({
|
||||||
|
required AppSettings settings,
|
||||||
|
}) async {
|
||||||
|
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.hour < 18) return;
|
||||||
|
|
||||||
|
final dateKey = _todayKey();
|
||||||
|
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||||
|
final log = worshipBox.get(dateKey);
|
||||||
|
if (log == null) return;
|
||||||
|
|
||||||
|
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
|
||||||
|
final dzikirRisk =
|
||||||
|
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
|
||||||
|
|
||||||
|
if (tilawahRisk) {
|
||||||
|
final title = 'Streak Tilawah berisiko terputus';
|
||||||
|
const body =
|
||||||
|
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
|
||||||
|
final dedupe = 'streak.tilawah.$dateKey';
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'streak_risk',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/quran',
|
||||||
|
dedupeKey: dedupe,
|
||||||
|
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||||
|
);
|
||||||
|
await _pushHabitIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.$dedupe',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dzikirRisk) {
|
||||||
|
final title = 'Dzikir petang belum tercatat';
|
||||||
|
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
|
||||||
|
final dedupe = 'streak.dzikir.$dateKey';
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: 'streak_risk',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/tools/dzikir',
|
||||||
|
dedupeKey: dedupe,
|
||||||
|
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
|
||||||
|
);
|
||||||
|
await _pushHabitIfAllowed(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: 'push.$dedupe',
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emitWeeklySummaryIfNeeded({
|
||||||
|
required AppSettings settings,
|
||||||
|
}) async {
|
||||||
|
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.weekday != DateTime.monday || now.hour < 6) return;
|
||||||
|
|
||||||
|
final monday = now.subtract(Duration(days: now.weekday - 1));
|
||||||
|
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
|
||||||
|
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
|
||||||
|
|
||||||
|
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||||
|
var completionDays = 0;
|
||||||
|
var totalPoints = 0;
|
||||||
|
|
||||||
|
for (int i = 1; i <= 7; i++) {
|
||||||
|
final date = now.subtract(Duration(days: i));
|
||||||
|
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||||
|
final log = worshipBox.get(key);
|
||||||
|
if (log == null) continue;
|
||||||
|
if (log.completionPercent >= 70) completionDays++;
|
||||||
|
totalPoints += log.totalPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: 'Ringkasan Ibadah Mingguan',
|
||||||
|
body:
|
||||||
|
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
|
||||||
|
type: 'summary',
|
||||||
|
source: 'local',
|
||||||
|
deeplink: '/laporan',
|
||||||
|
dedupeKey: 'summary.weekly.$weekKey',
|
||||||
|
expiresAt: now.add(const Duration(days: 7)),
|
||||||
|
);
|
||||||
|
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||||
|
|
||||||
|
Future<void> _pushSystemIfAllowed({
|
||||||
|
required AppSettings settings,
|
||||||
|
required String dedupeSeed,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
await _pushNonPrayer(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: dedupeSeed,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payloadType: 'system',
|
||||||
|
silent: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushHabitIfAllowed({
|
||||||
|
required AppSettings settings,
|
||||||
|
required String dedupeSeed,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
await _pushNonPrayer(
|
||||||
|
settings: settings,
|
||||||
|
dedupeSeed: dedupeSeed,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payloadType: 'streak_risk',
|
||||||
|
silent: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushNonPrayer({
|
||||||
|
required AppSettings settings,
|
||||||
|
required String dedupeSeed,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required String payloadType,
|
||||||
|
required bool silent,
|
||||||
|
}) async {
|
||||||
|
if (!settings.alertsEnabled) return;
|
||||||
|
final notif = NotificationService.instance;
|
||||||
|
await notif.showNonPrayerAlert(
|
||||||
|
settings: settings,
|
||||||
|
id: notif.nonPrayerNotificationId(dedupeSeed),
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payloadType: payloadType,
|
||||||
|
silent: silent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
lib/data/services/notification_inbox_service.dart
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
import '../local/hive_boxes.dart';
|
||||||
|
import 'notification_analytics_service.dart';
|
||||||
|
|
||||||
|
class NotificationInboxItem {
|
||||||
|
const NotificationInboxItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.type,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.readAt,
|
||||||
|
required this.isPinned,
|
||||||
|
required this.source,
|
||||||
|
required this.deeplink,
|
||||||
|
required this.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String type;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
final DateTime? readAt;
|
||||||
|
final bool isPinned;
|
||||||
|
final String source;
|
||||||
|
final String? deeplink;
|
||||||
|
final Map<String, dynamic> meta;
|
||||||
|
|
||||||
|
bool get isRead => readAt != null;
|
||||||
|
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'type': type,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'expiresAt': expiresAt?.toIso8601String(),
|
||||||
|
'readAt': readAt?.toIso8601String(),
|
||||||
|
'isPinned': isPinned,
|
||||||
|
'source': source,
|
||||||
|
'deeplink': deeplink,
|
||||||
|
'meta': meta,
|
||||||
|
};
|
||||||
|
|
||||||
|
static NotificationInboxItem fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
final createdRaw = (map['createdAt'] ?? '').toString();
|
||||||
|
final expiresRaw = (map['expiresAt'] ?? '').toString();
|
||||||
|
final readRaw = (map['readAt'] ?? '').toString();
|
||||||
|
final rawMeta = map['meta'];
|
||||||
|
|
||||||
|
return NotificationInboxItem(
|
||||||
|
id: (map['id'] ?? '').toString(),
|
||||||
|
title: (map['title'] ?? '').toString(),
|
||||||
|
body: (map['body'] ?? '').toString(),
|
||||||
|
type: (map['type'] ?? 'system').toString(),
|
||||||
|
createdAt: DateTime.tryParse(createdRaw) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
expiresAt: expiresRaw.isEmpty ? null : DateTime.tryParse(expiresRaw),
|
||||||
|
readAt: readRaw.isEmpty ? null : DateTime.tryParse(readRaw),
|
||||||
|
isPinned: map['isPinned'] == true,
|
||||||
|
source: (map['source'] ?? 'local').toString(),
|
||||||
|
deeplink: ((map['deeplink'] ?? '').toString().trim().isEmpty)
|
||||||
|
? null
|
||||||
|
: (map['deeplink'] ?? '').toString(),
|
||||||
|
meta: rawMeta is Map
|
||||||
|
? rawMeta.map((k, v) => MapEntry(k.toString(), v))
|
||||||
|
: const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationInboxService {
|
||||||
|
NotificationInboxService._();
|
||||||
|
static final NotificationInboxService instance = NotificationInboxService._();
|
||||||
|
|
||||||
|
Box get _box => Hive.box(HiveBoxes.notificationInbox);
|
||||||
|
|
||||||
|
ValueListenable<Box> listenable() => _box.listenable();
|
||||||
|
|
||||||
|
List<NotificationInboxItem> allItems({
|
||||||
|
String filter = 'all',
|
||||||
|
}) {
|
||||||
|
final items = _box.values
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((raw) => NotificationInboxItem.fromMap(raw))
|
||||||
|
.where((item) => !item.isExpired)
|
||||||
|
.where((item) {
|
||||||
|
switch (filter) {
|
||||||
|
case 'unread':
|
||||||
|
return !item.isRead;
|
||||||
|
case 'system':
|
||||||
|
return item.type == 'system';
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
if (a.isPinned != b.isPinned) {
|
||||||
|
return a.isPinned ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.createdAt.compareTo(a.createdAt);
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
int unreadCount() => allItems().where((e) => !e.isRead).length;
|
||||||
|
|
||||||
|
Future<void> addItem({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required String type,
|
||||||
|
String source = 'local',
|
||||||
|
String? deeplink,
|
||||||
|
String? dedupeKey,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
bool isPinned = false,
|
||||||
|
Map<String, dynamic> meta = const <String, dynamic>{},
|
||||||
|
}) async {
|
||||||
|
final key = dedupeKey ?? _defaultKey(type, title, body);
|
||||||
|
if (_box.containsKey(key)) {
|
||||||
|
final existingRaw = _box.get(key);
|
||||||
|
if (existingRaw is Map) {
|
||||||
|
final existing = NotificationInboxItem.fromMap(existingRaw);
|
||||||
|
await _box.put(
|
||||||
|
key,
|
||||||
|
existing
|
||||||
|
.copyWith(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: type,
|
||||||
|
source: source,
|
||||||
|
deeplink: deeplink,
|
||||||
|
expiresAt: expiresAt ?? existing.expiresAt,
|
||||||
|
isPinned: isPinned || existing.isPinned,
|
||||||
|
meta: meta.isEmpty ? existing.meta : meta,
|
||||||
|
)
|
||||||
|
.toMap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = NotificationInboxItem(
|
||||||
|
id: key,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: type,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
readAt: null,
|
||||||
|
isPinned: isPinned,
|
||||||
|
source: source,
|
||||||
|
deeplink: deeplink,
|
||||||
|
meta: meta,
|
||||||
|
);
|
||||||
|
await _box.put(key, item.toMap());
|
||||||
|
await NotificationAnalyticsService.instance.track(
|
||||||
|
'notif_inbox_created',
|
||||||
|
dimensions: <String, dynamic>{
|
||||||
|
'event_type': type,
|
||||||
|
'source': source,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRead(String id) async {
|
||||||
|
final raw = _box.get(id);
|
||||||
|
if (raw is! Map) return;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
if (item.isRead) return;
|
||||||
|
await _box.put(
|
||||||
|
id,
|
||||||
|
item.copyWith(readAt: DateTime.now()).toMap(),
|
||||||
|
);
|
||||||
|
await NotificationAnalyticsService.instance.track(
|
||||||
|
'notif_mark_read',
|
||||||
|
dimensions: <String, dynamic>{'event_type': item.type},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markUnread(String id) async {
|
||||||
|
final raw = _box.get(id);
|
||||||
|
if (raw is! Map) return;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
if (!item.isRead) return;
|
||||||
|
await _box.put(
|
||||||
|
id,
|
||||||
|
item.copyWith(readAt: null).toMap(),
|
||||||
|
);
|
||||||
|
await NotificationAnalyticsService.instance.track(
|
||||||
|
'notif_mark_unread',
|
||||||
|
dimensions: <String, dynamic>{'event_type': item.type},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
final updates = <dynamic, Map<String, dynamic>>{};
|
||||||
|
for (final key in _box.keys) {
|
||||||
|
final raw = _box.get(key);
|
||||||
|
if (raw is! Map) continue;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
if (item.isRead) continue;
|
||||||
|
updates[key] = item.copyWith(readAt: DateTime.now()).toMap();
|
||||||
|
}
|
||||||
|
if (updates.isNotEmpty) {
|
||||||
|
await _box.putAll(updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String id) async {
|
||||||
|
await _box.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeByType(String type) async {
|
||||||
|
final keys = <dynamic>[];
|
||||||
|
for (final key in _box.keys) {
|
||||||
|
final raw = _box.get(key);
|
||||||
|
if (raw is! Map) continue;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
if (item.type == type) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keys.isNotEmpty) {
|
||||||
|
await _box.deleteAll(keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> togglePinned(String id) async {
|
||||||
|
final raw = _box.get(id);
|
||||||
|
if (raw is! Map) return;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
await _box.put(
|
||||||
|
id,
|
||||||
|
item.copyWith(isPinned: !item.isPinned).toMap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeExpired() async {
|
||||||
|
final expiredKeys = <dynamic>[];
|
||||||
|
for (final key in _box.keys) {
|
||||||
|
final raw = _box.get(key);
|
||||||
|
if (raw is! Map) continue;
|
||||||
|
final item = NotificationInboxItem.fromMap(raw);
|
||||||
|
if (item.isExpired) expiredKeys.add(key);
|
||||||
|
}
|
||||||
|
if (expiredKeys.isNotEmpty) {
|
||||||
|
await _box.deleteAll(expiredKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _defaultKey(String type, String title, String body) {
|
||||||
|
final seed = '$type|$title|$body';
|
||||||
|
var hash = 17;
|
||||||
|
for (final rune in seed.runes) {
|
||||||
|
hash = 31 * hash + rune;
|
||||||
|
}
|
||||||
|
return 'inbox_${hash.abs()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on NotificationInboxItem {
|
||||||
|
static const _readAtUnchanged = Object();
|
||||||
|
|
||||||
|
NotificationInboxItem copyWith({
|
||||||
|
String? title,
|
||||||
|
String? body,
|
||||||
|
String? type,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
Object? readAt = _readAtUnchanged,
|
||||||
|
bool? isPinned,
|
||||||
|
String? source,
|
||||||
|
String? deeplink,
|
||||||
|
Map<String, dynamic>? meta,
|
||||||
|
}) {
|
||||||
|
return NotificationInboxItem(
|
||||||
|
id: id,
|
||||||
|
title: title ?? this.title,
|
||||||
|
body: body ?? this.body,
|
||||||
|
type: type ?? this.type,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
readAt: identical(readAt, _readAtUnchanged)
|
||||||
|
? this.readAt
|
||||||
|
: readAt as DateTime?,
|
||||||
|
isPinned: isPinned ?? this.isPinned,
|
||||||
|
source: source ?? this.source,
|
||||||
|
deeplink: deeplink ?? this.deeplink,
|
||||||
|
meta: meta ?? this.meta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/data/services/notification_orchestrator_service.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import '../local/models/app_settings.dart';
|
||||||
|
import 'notification_event_producer_service.dart';
|
||||||
|
import 'notification_inbox_service.dart';
|
||||||
|
import 'remote_notification_content_service.dart';
|
||||||
|
|
||||||
|
/// High-level coordinator for non-prayer notification flows.
|
||||||
|
class NotificationOrchestratorService {
|
||||||
|
NotificationOrchestratorService._();
|
||||||
|
static final NotificationOrchestratorService instance =
|
||||||
|
NotificationOrchestratorService._();
|
||||||
|
|
||||||
|
Future<void> runPassivePass({
|
||||||
|
required AppSettings settings,
|
||||||
|
}) async {
|
||||||
|
await NotificationInboxService.instance.removeExpired();
|
||||||
|
await NotificationEventProducerService.instance.emitStreakRiskIfNeeded(
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
await NotificationEventProducerService.instance.emitWeeklySummaryIfNeeded(
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
await RemoteNotificationContentService.instance.sync(settings: settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/data/services/notification_runtime_service.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../local/hive_boxes.dart';
|
||||||
|
import '../local/models/app_settings.dart';
|
||||||
|
|
||||||
|
/// Runtime persistence for notification counters and cursors.
|
||||||
|
class NotificationRuntimeService {
|
||||||
|
NotificationRuntimeService._();
|
||||||
|
static final NotificationRuntimeService instance =
|
||||||
|
NotificationRuntimeService._();
|
||||||
|
|
||||||
|
static const _nonPrayerCountPrefix = 'non_prayer_push_count.';
|
||||||
|
static const _lastRemoteSyncKey = 'remote.last_sync_at';
|
||||||
|
static const _lastWeeklySummaryKey = 'summary.last_week_key';
|
||||||
|
|
||||||
|
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
|
||||||
|
|
||||||
|
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||||
|
|
||||||
|
int nonPrayerPushCountToday() {
|
||||||
|
return (_box.get('$_nonPrayerCountPrefix${_todayKey()}') as int?) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> incrementNonPrayerPushCount() async {
|
||||||
|
final key = '$_nonPrayerCountPrefix${_todayKey()}';
|
||||||
|
final next = ((_box.get(key) as int?) ?? 0) + 1;
|
||||||
|
await _box.put(key, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isWithinQuietHours(AppSettings settings, {DateTime? now}) {
|
||||||
|
final current = now ?? DateTime.now();
|
||||||
|
final startParts = _parseHourMinute(settings.quietHoursStart);
|
||||||
|
final endParts = _parseHourMinute(settings.quietHoursEnd);
|
||||||
|
if (startParts == null || endParts == null) return false;
|
||||||
|
|
||||||
|
final currentMinutes = current.hour * 60 + current.minute;
|
||||||
|
final startMinutes = startParts.$1 * 60 + startParts.$2;
|
||||||
|
final endMinutes = endParts.$1 * 60 + endParts.$2;
|
||||||
|
|
||||||
|
if (startMinutes == endMinutes) {
|
||||||
|
// Same value means quiet-hours disabled.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (startMinutes < endMinutes) {
|
||||||
|
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||||
|
}
|
||||||
|
// Overnight interval (e.g. 22:00 -> 05:00).
|
||||||
|
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canSendNonPrayerPush(AppSettings settings, {DateTime? now}) {
|
||||||
|
if (!settings.alertsEnabled) return false;
|
||||||
|
if (isWithinQuietHours(settings, now: now)) return false;
|
||||||
|
return nonPrayerPushCountToday() < settings.maxNonPrayerPushPerDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? lastRemoteSyncAt() {
|
||||||
|
final raw = (_box.get(_lastRemoteSyncKey) ?? '').toString();
|
||||||
|
if (raw.isEmpty) return null;
|
||||||
|
return DateTime.tryParse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLastRemoteSyncAt(DateTime value) async {
|
||||||
|
await _box.put(_lastRemoteSyncKey, value.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastWeeklySummaryWeekKey() {
|
||||||
|
final raw = (_box.get(_lastWeeklySummaryKey) ?? '').toString();
|
||||||
|
return raw.isEmpty ? null : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLastWeeklySummaryWeekKey(String key) async {
|
||||||
|
await _box.put(_lastWeeklySummaryKey, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int)? _parseHourMinute(String value) {
|
||||||
|
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
|
||||||
|
if (match == null) return null;
|
||||||
|
final hour = int.tryParse(match.group(1) ?? '');
|
||||||
|
final minute = int.tryParse(match.group(2) ?? '');
|
||||||
|
if (hour == null || minute == null) return null;
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||||
|
return (hour, minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,43 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tz_data;
|
||||||
import 'package:timezone/timezone.dart' as tz;
|
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 {
|
class NotificationService {
|
||||||
NotificationService._();
|
NotificationService._();
|
||||||
static final NotificationService instance = NotificationService._();
|
static final NotificationService instance = NotificationService._();
|
||||||
@@ -10,16 +46,100 @@ class NotificationService {
|
|||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
bool _initialized = false;
|
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 {
|
Future<void> init() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
tz_data.initializeTimeZones();
|
||||||
|
_configureLocalTimeZone();
|
||||||
|
|
||||||
|
const androidSettings =
|
||||||
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
const darwinSettings = DarwinInitializationSettings(
|
const darwinSettings = DarwinInitializationSettings(
|
||||||
requestAlertPermission: true,
|
requestAlertPermission: false,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: false,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const settings = InitializationSettings(
|
const settings = InitializationSettings(
|
||||||
@@ -28,71 +148,509 @@ class NotificationService {
|
|||||||
macOS: darwinSettings,
|
macOS: darwinSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _plugin.initialize(settings);
|
await _plugin.initialize(settings: settings);
|
||||||
|
await _requestPermissions();
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule an Adhan notification at a specific time.
|
void _configureLocalTimeZone() {
|
||||||
Future<void> scheduleAdhan({
|
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 int id,
|
||||||
required String prayerName,
|
required String prayerName,
|
||||||
required DateTime time,
|
required DateTime time,
|
||||||
}) async {
|
}) async {
|
||||||
await _plugin.zonedSchedule(
|
await _plugin.zonedSchedule(
|
||||||
id,
|
id: id,
|
||||||
'Adhan - $prayerName',
|
title: 'Adzan • $prayerName',
|
||||||
'It\'s time for $prayerName prayer',
|
body: 'Waktu sholat $prayerName telah masuk.',
|
||||||
tz.TZDateTime.from(time, tz.local),
|
scheduledDate: tz.TZDateTime.from(time, tz.local),
|
||||||
const NotificationDetails(
|
notificationDetails: _adhanDetails,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
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 int id,
|
||||||
required String prayerName,
|
required String prayerName,
|
||||||
required DateTime adhanTime,
|
required DateTime iqamahTime,
|
||||||
required int offsetMinutes,
|
required int offsetMinutes,
|
||||||
}) async {
|
}) async {
|
||||||
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
|
|
||||||
await _plugin.zonedSchedule(
|
await _plugin.zonedSchedule(
|
||||||
id + 100, // Offset IDs for iqamah
|
id: id,
|
||||||
'Iqamah - $prayerName',
|
title: 'Iqamah • $prayerName',
|
||||||
'Iqamah for $prayerName in $offsetMinutes minutes',
|
body: 'Iqamah $prayerName dalam $offsetMinutes menit.',
|
||||||
tz.TZDateTime.from(iqamahTime, tz.local),
|
scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local),
|
||||||
const NotificationDetails(
|
notificationDetails: _iqamahDetails,
|
||||||
android: AndroidNotificationDetails(
|
|
||||||
'iqamah_channel',
|
|
||||||
'Iqamah Reminders',
|
|
||||||
channelDescription: 'Iqamah reminder notifications',
|
|
||||||
importance: Importance.defaultImportance,
|
|
||||||
priority: Priority.defaultPriority,
|
|
||||||
),
|
|
||||||
iOS: DarwinNotificationDetails(
|
|
||||||
presentAlert: true,
|
|
||||||
presentSound: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
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.
|
DateTime? _parseScheduleDateTime(DateTime date, String hhmm) {
|
||||||
Future<void> cancelAll() async {
|
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();
|
await _plugin.cancelAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> pendingCount() async {
|
||||||
|
final pending = await _plugin.pendingNotificationRequests();
|
||||||
|
return pending.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> syncHabitNotifications({
|
||||||
|
required AppSettings settings,
|
||||||
|
}) async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) {
|
||||||
|
await cancelChecklistReminder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reminderTime = settings.checklistReminderTime ?? '09:00';
|
||||||
|
final parts = _parseHourMinute(reminderTime);
|
||||||
|
if (parts == null) {
|
||||||
|
await cancelChecklistReminder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
var target = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
parts.$1,
|
||||||
|
parts.$2,
|
||||||
|
);
|
||||||
|
if (!target.isAfter(now)) {
|
||||||
|
target = target.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
id: _checklistReminderId,
|
||||||
|
title: 'Checklist Ibadah Harian',
|
||||||
|
body: 'Jangan lupa perbarui progres ibadah hari ini.',
|
||||||
|
scheduledDate: tz.TZDateTime.from(target, tz.local),
|
||||||
|
notificationDetails: _habitDetails,
|
||||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
matchDateTimeComponents: DateTimeComponents.time,
|
||||||
|
payload: 'checklist|daily|${target.toIso8601String()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelChecklistReminder() async {
|
||||||
|
await _plugin.cancel(id: _checklistReminderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
int nonPrayerNotificationId(String seed) {
|
||||||
|
var hash = 17;
|
||||||
|
for (final rune in seed.runes) {
|
||||||
|
hash = 41 * hash + rune;
|
||||||
|
}
|
||||||
|
return 900000 + (hash.abs() % 80000);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showNonPrayerAlert({
|
||||||
|
required AppSettings settings,
|
||||||
|
required int id,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
String payloadType = 'system',
|
||||||
|
bool silent = false,
|
||||||
|
bool bypassQuietHours = false,
|
||||||
|
bool bypassDailyCap = false,
|
||||||
|
}) async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
final runtime = NotificationRuntimeService.instance;
|
||||||
|
if (!settings.alertsEnabled) return false;
|
||||||
|
if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false;
|
||||||
|
if (!bypassDailyCap &&
|
||||||
|
runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _plugin.show(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: silent ? _systemDetails : _habitDetails,
|
||||||
|
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bypassDailyCap) {
|
||||||
|
await runtime.incrementNonPrayerPushCount();
|
||||||
|
}
|
||||||
|
await NotificationAnalyticsService.instance.track(
|
||||||
|
'notif_push_fired',
|
||||||
|
dimensions: <String, dynamic>{
|
||||||
|
'event_type': payloadType,
|
||||||
|
'channel': 'push',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<NotificationPermissionStatus> getPermissionStatus() async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>();
|
||||||
|
final notificationsAllowed =
|
||||||
|
await androidPlugin?.areNotificationsEnabled() ?? true;
|
||||||
|
final exactAlarmAllowed =
|
||||||
|
await androidPlugin?.canScheduleExactNotifications() ?? true;
|
||||||
|
return NotificationPermissionStatus(
|
||||||
|
notificationsAllowed: notificationsAllowed,
|
||||||
|
exactAlarmAllowed: exactAlarmAllowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>();
|
||||||
|
final options = await iosPlugin?.checkPermissions();
|
||||||
|
return NotificationPermissionStatus(
|
||||||
|
notificationsAllowed: options?.isEnabled ?? true,
|
||||||
|
exactAlarmAllowed: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
final macPlugin = _plugin.resolvePlatformSpecificImplementation<
|
||||||
|
MacOSFlutterLocalNotificationsPlugin>();
|
||||||
|
final options = await macPlugin?.checkPermissions();
|
||||||
|
return NotificationPermissionStatus(
|
||||||
|
notificationsAllowed: options?.isEnabled ?? true,
|
||||||
|
exactAlarmAllowed: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback to non-blocking defaults if platform query fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return const NotificationPermissionStatus(
|
||||||
|
notificationsAllowed: true,
|
||||||
|
exactAlarmAllowed: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<NotificationPendingAlert>> pendingAlerts() async {
|
||||||
|
final pending = await _plugin.pendingNotificationRequests();
|
||||||
|
final alerts = pending.map(_mapPendingRequest).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aTime = a.scheduledAt;
|
||||||
|
final bTime = b.scheduledAt;
|
||||||
|
if (aTime == null && bTime == null) return a.id.compareTo(b.id);
|
||||||
|
if (aTime == null) return 1;
|
||||||
|
if (bTime == null) return -1;
|
||||||
|
return aTime.compareTo(bTime);
|
||||||
|
});
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) {
|
||||||
|
final payload = raw.payload ?? '';
|
||||||
|
final parts = payload.split('|');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
final type = parts[0].trim().toLowerCase();
|
||||||
|
final title = raw.title ?? '${_labelForType(type)} • ${parts[1].trim()}';
|
||||||
|
final body = raw.body ?? '';
|
||||||
|
final scheduledAt = DateTime.tryParse(parts[2].trim());
|
||||||
|
return NotificationPendingAlert(
|
||||||
|
id: raw.id,
|
||||||
|
type: type,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
scheduledAt: scheduledAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fallbackType = _inferTypeFromTitle(raw.title ?? '');
|
||||||
|
return NotificationPendingAlert(
|
||||||
|
id: raw.id,
|
||||||
|
type: fallbackType,
|
||||||
|
title: raw.title ?? 'Pengingat',
|
||||||
|
body: raw.body ?? '',
|
||||||
|
scheduledAt: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _inferTypeFromTitle(String title) {
|
||||||
|
final normalized = title.toLowerCase();
|
||||||
|
if (normalized.contains('iqamah')) return 'iqamah';
|
||||||
|
if (normalized.contains('adzan')) return 'adhan';
|
||||||
|
return 'alert';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _labelForType(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'adhan':
|
||||||
|
return 'Adzan';
|
||||||
|
case 'iqamah':
|
||||||
|
return 'Iqamah';
|
||||||
|
case 'checklist':
|
||||||
|
return 'Checklist';
|
||||||
|
case 'streak_risk':
|
||||||
|
return 'Streak';
|
||||||
|
case 'system':
|
||||||
|
return 'Sistem';
|
||||||
|
default:
|
||||||
|
return 'Pengingat';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int)? _parseHourMinute(String hhmm) {
|
||||||
|
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim());
|
||||||
|
if (match == null) return null;
|
||||||
|
final hour = int.tryParse(match.group(1) ?? '');
|
||||||
|
final minute = int.tryParse(match.group(2) ?? '');
|
||||||
|
if (hour == null || minute == null) return null;
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||||
|
return (hour, minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
104
lib/data/services/remote_notification_content_service.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../local/models/app_settings.dart';
|
||||||
|
import 'notification_inbox_service.dart';
|
||||||
|
import 'notification_runtime_service.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
|
/// Pulls server-defined notification content and maps it to local inbox items.
|
||||||
|
class RemoteNotificationContentService {
|
||||||
|
RemoteNotificationContentService._();
|
||||||
|
static final RemoteNotificationContentService instance =
|
||||||
|
RemoteNotificationContentService._();
|
||||||
|
|
||||||
|
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||||
|
final NotificationRuntimeService _runtime =
|
||||||
|
NotificationRuntimeService.instance;
|
||||||
|
|
||||||
|
Future<void> sync({
|
||||||
|
required AppSettings settings,
|
||||||
|
}) async {
|
||||||
|
if (!settings.inboxEnabled) return;
|
||||||
|
|
||||||
|
final endpoint = (dotenv.env['NOTIFICATION_FEED_URL'] ?? '').trim();
|
||||||
|
if (endpoint.isEmpty) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final lastSync = _runtime.lastRemoteSyncAt();
|
||||||
|
if (lastSync != null &&
|
||||||
|
now.difference(lastSync) < const Duration(hours: 6)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse(endpoint));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) return;
|
||||||
|
|
||||||
|
final decoded = json.decode(response.body);
|
||||||
|
final items = _extractItems(decoded);
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
|
||||||
|
for (final raw in items) {
|
||||||
|
final id = (raw['id'] ?? '').toString().trim();
|
||||||
|
final title = (raw['title'] ?? '').toString().trim();
|
||||||
|
final body = (raw['body'] ?? '').toString().trim();
|
||||||
|
if (id.isEmpty || title.isEmpty || body.isEmpty) continue;
|
||||||
|
|
||||||
|
final deeplink = (raw['deeplink'] ?? '').toString().trim();
|
||||||
|
final type = (raw['type'] ?? 'content').toString().trim();
|
||||||
|
final expiresAt =
|
||||||
|
DateTime.tryParse((raw['expiresAt'] ?? '').toString().trim());
|
||||||
|
final isPinned = raw['isPinned'] == true;
|
||||||
|
final shouldPush = raw['push'] == true;
|
||||||
|
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: type.isEmpty ? 'content' : type,
|
||||||
|
source: 'remote',
|
||||||
|
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||||
|
dedupeKey: 'remote.$id',
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
isPinned: isPinned,
|
||||||
|
meta: <String, dynamic>{'remoteId': id},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldPush && settings.alertsEnabled) {
|
||||||
|
final notif = NotificationService.instance;
|
||||||
|
await notif.showNonPrayerAlert(
|
||||||
|
settings: settings,
|
||||||
|
id: notif.nonPrayerNotificationId('remote.push.$id'),
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payloadType: 'content',
|
||||||
|
silent: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _runtime.setLastRemoteSyncAt(now);
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal: remote feed is optional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _extractItems(dynamic decoded) {
|
||||||
|
if (decoded is List) {
|
||||||
|
return decoded.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||||
|
}
|
||||||
|
if (decoded is Map) {
|
||||||
|
final list = decoded['items'];
|
||||||
|
if (list is List) {
|
||||||
|
return list.whereType<Map>().map(_toStringKeyedMap).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _toStringKeyedMap(Map raw) {
|
||||||
|
return raw.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/data/services/remote_push_service.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import '../local/models/app_settings.dart';
|
||||||
|
import 'notification_inbox_service.dart';
|
||||||
|
|
||||||
|
/// Phase-4 bridge for future FCM/APNs wiring.
|
||||||
|
///
|
||||||
|
/// This app currently ships without Firebase/APNs SDK setup in source control.
|
||||||
|
/// Once push SDK is configured, route incoming payloads to [ingestPayload].
|
||||||
|
class RemotePushService {
|
||||||
|
RemotePushService._();
|
||||||
|
static final RemotePushService instance = RemotePushService._();
|
||||||
|
|
||||||
|
final NotificationInboxService _inbox = NotificationInboxService.instance;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> ingestPayload(
|
||||||
|
Map<String, dynamic> payload, {
|
||||||
|
AppSettings? settings,
|
||||||
|
}) async {
|
||||||
|
if (settings != null && !settings.inboxEnabled) return;
|
||||||
|
|
||||||
|
final id = (payload['id'] ?? payload['messageId'] ?? '').toString().trim();
|
||||||
|
final title = (payload['title'] ?? '').toString().trim();
|
||||||
|
final body = (payload['body'] ?? '').toString().trim();
|
||||||
|
if (id.isEmpty || title.isEmpty || body.isEmpty) return;
|
||||||
|
|
||||||
|
final type = (payload['type'] ?? 'content').toString().trim();
|
||||||
|
final deeplink = (payload['deeplink'] ?? '').toString().trim();
|
||||||
|
final expiresAt =
|
||||||
|
DateTime.tryParse((payload['expiresAt'] ?? '').toString().trim());
|
||||||
|
final isPinned = payload['isPinned'] == true;
|
||||||
|
|
||||||
|
await _inbox.addItem(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
type: type.isEmpty ? 'content' : type,
|
||||||
|
source: 'remote',
|
||||||
|
deeplink: deeplink.isEmpty ? null : deeplink,
|
||||||
|
dedupeKey: 'remote.push.$id',
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
isPinned: isPinned,
|
||||||
|
meta: <String, dynamic>{'remoteId': id},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../app/theme/app_colors.dart';
|
import '../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/notification_bell_button.dart';
|
||||||
import '../../../core/widgets/progress_bar.dart';
|
import '../../../core/widgets/progress_bar.dart';
|
||||||
import '../../../data/local/hive_boxes.dart';
|
import '../../../data/local/hive_boxes.dart';
|
||||||
import '../../../data/local/models/app_settings.dart';
|
import '../../../data/local/models/app_settings.dart';
|
||||||
@@ -27,7 +28,13 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
late Box<AppSettings> _settingsBox;
|
late Box<AppSettings> _settingsBox;
|
||||||
late AppSettings _settings;
|
late AppSettings _settings;
|
||||||
|
|
||||||
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
|
final List<String> _fardhuPrayers = [
|
||||||
|
'Subuh',
|
||||||
|
'Dzuhur',
|
||||||
|
'Ashar',
|
||||||
|
'Maghrib',
|
||||||
|
'Isya'
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -69,7 +76,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
final log = _todayLog;
|
final log = _todayLog;
|
||||||
|
|
||||||
// Lazily attach Dzikir and Puasa if user toggles them mid-day
|
// 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();
|
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
@@ -155,17 +163,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
const NotificationBellButton(),
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(LucideIcons.bell),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
icon: const Icon(LucideIcons.settings),
|
icon: const Icon(LucideIcons.settings),
|
||||||
@@ -246,14 +253,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary.withValues(alpha: 0.15),
|
color: AppColors.primary.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(LucideIcons.star, color: AppColors.primary, size: 14),
|
const Icon(LucideIcons.star,
|
||||||
|
color: AppColors.primary, size: 14),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'${log.totalPoints} pts',
|
'${log.totalPoints} pts',
|
||||||
@@ -334,7 +343,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? AppColors.primary.withValues(alpha: 0.3)
|
? 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(
|
child: Theme(
|
||||||
@@ -347,10 +358,14 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? AppColors.primary.withValues(alpha: 0.15)
|
? 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),
|
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(
|
title: Text(
|
||||||
'Sholat $prayerName',
|
'Sholat $prayerName',
|
||||||
@@ -362,7 +377,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: log.location != null
|
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,
|
: null,
|
||||||
trailing: _CustomCheckbox(
|
trailing: _CustomCheckbox(
|
||||||
value: isCompleted,
|
value: isCompleted,
|
||||||
@@ -371,14 +388,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
_recalculateProgress();
|
_recalculateProgress();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
childrenPadding:
|
||||||
|
const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||||
children: [
|
children: [
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Location Radio
|
// Location Radio
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 16),
|
||||||
_radioOption('Masjid', log, () {
|
_radioOption('Masjid', log, () {
|
||||||
log.location = 'Masjid';
|
log.location = 'Masjid';
|
||||||
@@ -422,7 +442,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
color: selected ? AppColors.primary : Colors.grey,
|
color: selected ? AppColors.primary : Colors.grey,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
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(
|
border: Border.all(
|
||||||
color: log.isCompleted
|
color: log.isCompleted
|
||||||
? AppColors.primary.withValues(alpha: 0.3)
|
? 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(
|
child: Column(
|
||||||
@@ -467,10 +491,15 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: log.isCompleted
|
color: log.isCompleted
|
||||||
? AppColors.primary.withValues(alpha: 0.15)
|
? 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),
|
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),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -482,13 +511,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
color: log.isCompleted && isDark
|
||||||
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
|
? AppColors.textSecondaryDark
|
||||||
|
: null,
|
||||||
|
decoration:
|
||||||
|
log.isCompleted ? TextDecoration.lineThrough : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Target: ${log.targetValue} ${log.targetUnit}',
|
'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(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (log.autoSync)
|
if (log.autoSync)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: 'Sinkron dari Al-Quran',
|
message: 'Sinkron dari Al-Quran',
|
||||||
child: Icon(LucideIcons.refreshCw, size: 16, color: AppColors.primary),
|
child: Icon(LucideIcons.refreshCw,
|
||||||
|
size: 16, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(LucideIcons.minusCircle, size: 20),
|
icon: const Icon(LucideIcons.minusCircle, size: 20),
|
||||||
@@ -536,7 +572,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(LucideIcons.plusCircle, size: 20, color: AppColors.primary),
|
icon: const Icon(LucideIcons.plusCircle,
|
||||||
|
size: 20, color: AppColors.primary),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
log.rawAyatRead++;
|
log.rawAyatRead++;
|
||||||
@@ -568,7 +605,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.sparkles, size: 20, color: AppColors.sage),
|
Icon(LucideIcons.sparkles, size: 20, color: AppColors.sage),
|
||||||
const SizedBox(width: 8),
|
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),
|
const SizedBox(height: 12),
|
||||||
@@ -599,13 +637,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(LucideIcons.moonStar, size: 20, color: AppColors.sage),
|
const Icon(LucideIcons.moonStar, size: 20, color: AppColors.sage),
|
||||||
const SizedBox(width: 8),
|
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>(
|
DropdownButton<String>(
|
||||||
value: log.jenisPuasa,
|
value: log.jenisPuasa,
|
||||||
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
|
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
|
||||||
underline: const SizedBox(),
|
underline: const SizedBox(),
|
||||||
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
|
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(),
|
.toList(),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
log.jenisPuasa = v;
|
log.jenisPuasa = v;
|
||||||
@@ -644,7 +686,9 @@ class _CustomCheckbox extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: value ? null : Border.all(color: Colors.grey, width: 2),
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:intl/intl.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/myquran_sholat_service.dart';
|
||||||
|
import '../../../data/services/notification_service.dart';
|
||||||
import '../../../data/services/prayer_service.dart';
|
import '../../../data/services/prayer_service.dart';
|
||||||
import '../../../data/services/location_service.dart';
|
import '../../../data/services/location_service.dart';
|
||||||
import '../../../data/local/hive_boxes.dart';
|
import '../../../data/local/hive_boxes.dart';
|
||||||
@@ -25,7 +30,8 @@ class DaySchedule {
|
|||||||
final String province;
|
final String province;
|
||||||
final String date; // yyyy-MM-dd
|
final String date; // yyyy-MM-dd
|
||||||
final String tanggal; // formatted date from API
|
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({
|
DaySchedule({
|
||||||
required this.cityName,
|
required this.cityName,
|
||||||
@@ -65,7 +71,8 @@ class DaySchedule {
|
|||||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||||
} else {
|
} else {
|
||||||
for (int i = 0; i < prayers.length; i++) {
|
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;
|
activeIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -136,8 +143,8 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
|||||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||||
|
|
||||||
final tmrwJadwal =
|
final tmrwJadwal = await MyQuranSholatService.instance
|
||||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
.getDailySchedule(cityId, tomorrowStr);
|
||||||
|
|
||||||
if (tmrwJadwal != null) {
|
if (tmrwJadwal != null) {
|
||||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||||
@@ -152,37 +159,106 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schedule != null) {
|
if (schedule != null) {
|
||||||
|
unawaited(_syncAdhanNotifications(cityId, schedule));
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to adhan package
|
// Fallback to adhan package
|
||||||
final position = await LocationService.instance.getCurrentLocation();
|
final position = await LocationService.instance.getCurrentLocation();
|
||||||
double lat = position?.latitude ?? -6.2088;
|
final lat = position?.latitude ?? -6.2088;
|
||||||
double lng = position?.longitude ?? 106.8456;
|
final lng = position?.longitude ?? 106.8456;
|
||||||
|
final locationUnavailable = position == null;
|
||||||
|
|
||||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
final result =
|
||||||
if (result != null) {
|
PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||||
final timeFormat = DateFormat('HH:mm');
|
final timeFormat = DateFormat('HH:mm');
|
||||||
return DaySchedule(
|
final fallbackSchedule = DaySchedule(
|
||||||
cityName: 'Jakarta',
|
cityName: 'Jakarta',
|
||||||
province: 'DKI Jakarta',
|
province: 'DKI Jakarta',
|
||||||
date: today,
|
date: today,
|
||||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||||
times: {
|
times: {
|
||||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
'imsak':
|
||||||
|
timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||||
'subuh': timeFormat.format(result.fajr),
|
'subuh': timeFormat.format(result.fajr),
|
||||||
'terbit': timeFormat.format(result.sunrise),
|
'terbit': timeFormat.format(result.sunrise),
|
||||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
'dhuha':
|
||||||
|
timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||||
'dzuhur': timeFormat.format(result.dhuhr),
|
'dzuhur': timeFormat.format(result.dhuhr),
|
||||||
'ashar': timeFormat.format(result.asr),
|
'ashar': timeFormat.format(result.asr),
|
||||||
'maghrib': timeFormat.format(result.maghrib),
|
'maghrib': timeFormat.format(result.maghrib),
|
||||||
'isya': timeFormat.format(result.isha),
|
'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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||||
final monthlyScheduleProvider =
|
final monthlyScheduleProvider =
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../app/icons/app_icons.dart';
|
||||||
import '../../../app/theme/app_colors.dart';
|
import '../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/arabic_text.dart';
|
||||||
|
import '../../../core/widgets/ayat_today_card.dart';
|
||||||
|
import '../../../core/widgets/notification_bell_button.dart';
|
||||||
import '../../../core/widgets/prayer_time_card.dart';
|
import '../../../core/widgets/prayer_time_card.dart';
|
||||||
import '../../../core/widgets/tool_card.dart';
|
import '../../../core/widgets/tool_card.dart';
|
||||||
import '../../../data/local/hive_boxes.dart';
|
import '../../../data/local/hive_boxes.dart';
|
||||||
import '../../../data/local/models/app_settings.dart';
|
import '../../../data/local/models/app_settings.dart';
|
||||||
import '../../../data/local/models/daily_worship_log.dart';
|
import '../../../data/local/models/daily_worship_log.dart';
|
||||||
import '../../../data/services/equran_service.dart';
|
import '../../../data/local/models/quran_bookmark.dart';
|
||||||
|
import '../../../data/services/notification_service.dart';
|
||||||
import '../data/prayer_times_provider.dart';
|
import '../data/prayer_times_provider.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerStatefulWidget {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
@@ -25,8 +30,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Timer? _countdownTimer;
|
Timer? _countdownTimer;
|
||||||
final ValueNotifier<Duration> _countdown = ValueNotifier(Duration.zero);
|
final ValueNotifier<Duration> _countdown = ValueNotifier(Duration.zero);
|
||||||
final ValueNotifier<String> _nextPrayerName = ValueNotifier('');
|
final ValueNotifier<String> _nextPrayerName = ValueNotifier('');
|
||||||
|
final ValueNotifier<String> _nextPrayerTime = ValueNotifier('');
|
||||||
final ScrollController _prayerScrollController = ScrollController();
|
final ScrollController _prayerScrollController = ScrollController();
|
||||||
bool _hasAutoScrolled = false;
|
bool _hasAutoScrolled = false;
|
||||||
|
String? _lastAutoScrollPrayerKey;
|
||||||
DaySchedule? _currentSchedule;
|
DaySchedule? _currentSchedule;
|
||||||
|
|
||||||
bool get _isSimpleMode {
|
bool get _isSimpleMode {
|
||||||
@@ -35,17 +42,55 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return settings?.simpleMode ?? false;
|
return settings?.simpleMode ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _isAdhanEnabled {
|
||||||
|
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
final settings = box.get('default');
|
||||||
|
return settings?.adhanEnabled.values.any((v) => v) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleAdhanFromHero() async {
|
||||||
|
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
final settings = box.get('default') ?? AppSettings();
|
||||||
|
final nextEnabled = !settings.adhanEnabled.values.any((v) => v);
|
||||||
|
|
||||||
|
settings.adhanEnabled.updateAll((key, _) => nextEnabled);
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
if (!nextEnabled) {
|
||||||
|
await NotificationService.instance.cancelAllPending();
|
||||||
|
}
|
||||||
|
ref.invalidate(prayerTimesProvider);
|
||||||
|
unawaited(ref.read(prayerTimesProvider.future));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
nextEnabled
|
||||||
|
? 'Notifikasi adzan diaktifkan'
|
||||||
|
: 'Notifikasi adzan dinonaktifkan',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_countdownTimer?.cancel();
|
_countdownTimer?.cancel();
|
||||||
_prayerScrollController.dispose();
|
_prayerScrollController.dispose();
|
||||||
_countdown.dispose();
|
_countdown.dispose();
|
||||||
_nextPrayerName.dispose();
|
_nextPrayerName.dispose();
|
||||||
|
_nextPrayerTime.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown(DaySchedule schedule) {
|
void _startCountdown(DaySchedule schedule) {
|
||||||
if (_currentSchedule == schedule) return;
|
if (_currentSchedule == schedule) return;
|
||||||
|
if (_currentSchedule?.date != schedule.date) {
|
||||||
|
_hasAutoScrolled = false;
|
||||||
|
_lastAutoScrollPrayerKey = null;
|
||||||
|
}
|
||||||
_currentSchedule = schedule;
|
_currentSchedule = schedule;
|
||||||
|
|
||||||
_countdownTimer?.cancel();
|
_countdownTimer?.cancel();
|
||||||
@@ -56,21 +101,68 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _updateCountdown(DaySchedule schedule) {
|
void _updateCountdown(DaySchedule schedule) {
|
||||||
final next = schedule.nextPrayer;
|
|
||||||
if (next != null && next.time != '-') {
|
|
||||||
final parts = next.time.split(':');
|
|
||||||
if (parts.length == 2) {
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
var target = DateTime(now.year, now.month, now.day,
|
final next = _resolveNextPrayer(schedule, now);
|
||||||
int.parse(parts[0]), int.parse(parts[1]));
|
if (next == null) {
|
||||||
if (target.isBefore(now)) {
|
_nextPrayerName.value = '';
|
||||||
target = target.add(const Duration(days: 1));
|
_nextPrayerTime.value = '';
|
||||||
|
_countdown.value = Duration.zero;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_nextPrayerName.value = next.name;
|
_nextPrayerName.value = next.name;
|
||||||
final diff = target.difference(now);
|
_nextPrayerTime.value = next.time;
|
||||||
|
final diff = next.target.difference(now);
|
||||||
_countdown.value = diff.isNegative ? Duration.zero : diff;
|
_countdown.value = diff.isNegative ? Duration.zero : diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({String name, String time, DateTime target})? _resolveNextPrayer(
|
||||||
|
DaySchedule schedule, DateTime now) {
|
||||||
|
final scheduleDate = DateTime.tryParse(schedule.date) ??
|
||||||
|
DateTime(now.year, now.month, now.day);
|
||||||
|
final entries = <({String name, String time, DateTime target})>[];
|
||||||
|
|
||||||
|
const orderedPrayers = <MapEntry<String, String>>[
|
||||||
|
MapEntry('Subuh', 'subuh'),
|
||||||
|
MapEntry('Dzuhur', 'dzuhur'),
|
||||||
|
MapEntry('Ashar', 'ashar'),
|
||||||
|
MapEntry('Maghrib', 'maghrib'),
|
||||||
|
MapEntry('Isya', 'isya'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final prayer in orderedPrayers) {
|
||||||
|
final time = (schedule.times[prayer.value] ?? '').trim();
|
||||||
|
final parts = _parseHourMinute(time);
|
||||||
|
if (parts == null) continue;
|
||||||
|
final target = DateTime(scheduleDate.year, scheduleDate.month,
|
||||||
|
scheduleDate.day, parts.$1, parts.$2);
|
||||||
|
entries.add((name: prayer.key, time: time, target: target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
for (final entry in entries) {
|
||||||
|
if (!entry.target.isBefore(now)) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final first = entries.first;
|
||||||
|
return (
|
||||||
|
name: first.name,
|
||||||
|
time: first.time,
|
||||||
|
target: first.target.add(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, int)? _parseHourMinute(String value) {
|
||||||
|
final match = RegExp(r'(\d{1,2}):(\d{2})').firstMatch(value);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatCountdown(Duration d) {
|
String _formatCountdown(Duration d) {
|
||||||
@@ -125,14 +217,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return _buildPrayerTimesSection(context, prayerTimesAsync);
|
return _buildPrayerTimesSection(context, prayerTimesAsync);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLastReadQuranCard(context, isDark),
|
||||||
// Checklist & Weekly Progress (hidden in Simple Mode)
|
// Checklist & Weekly Progress (hidden in Simple Mode)
|
||||||
if (!_isSimpleMode) ...[
|
if (!_isSimpleMode) ...[
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildChecklistSummary(context, isDark),
|
_buildChecklistSummary(context, isDark),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildWeeklyProgress(context, isDark),
|
_buildWeeklyProgress(context, isDark),
|
||||||
] else ...[
|
] else ...[
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildQuickActions(context, isDark),
|
_buildQuickActions(context, isDark),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildAyatHariIni(context, isDark),
|
_buildAyatHariIni(context, isDark),
|
||||||
@@ -145,6 +237,184 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _quranReadingRoute(QuranBookmark bookmark) {
|
||||||
|
final base = _isSimpleMode ? '/quran' : '/tools/quran';
|
||||||
|
return '$base/${bookmark.surahId}?startVerse=${bookmark.verseId}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLastReadQuranCard(BuildContext context, bool isDark) {
|
||||||
|
return ValueListenableBuilder<Box<QuranBookmark>>(
|
||||||
|
valueListenable:
|
||||||
|
Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
|
||||||
|
builder: (context, box, _) {
|
||||||
|
final lastRead = box.values
|
||||||
|
.where((bookmark) => bookmark.isLastRead)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||||
|
|
||||||
|
if (lastRead.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookmark = lastRead.first;
|
||||||
|
final arabic = bookmark.verseText.trim();
|
||||||
|
final translation = (bookmark.verseTranslation ?? '').trim();
|
||||||
|
final dateLabel = DateFormat('dd MMM • HH:mm').format(bookmark.savedAt);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'LANJUTKAN TILAWAH',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: AppColors.sage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => context.push(_quranReadingRoute(bookmark)),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.22),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primary
|
||||||
|
.withValues(alpha: isDark ? 0.10 : 0.08),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.bookOpen,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'QS. ${bookmark.surahName}: ${bookmark.verseId}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Terakhir dibaca $dateLabel',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 18,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (arabic.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ArabicText(
|
||||||
|
arabic,
|
||||||
|
baseFontSize: 21,
|
||||||
|
height: 1.75,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (translation.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
translation,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.bookMarked,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Lanjutkan Membaca',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, bool isDark) {
|
Widget _buildHeader(BuildContext context, bool isDark) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -156,7 +426,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
border: Border.all(color: AppColors.primary, width: 2),
|
border: Border.all(color: AppColors.primary, width: 2),
|
||||||
color: AppColors.primary.withValues(alpha: 0.2),
|
color: AppColors.primary.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.user, size: 20, color: AppColors.primary),
|
child:
|
||||||
|
const Icon(LucideIcons.user, size: 20, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -182,19 +453,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
NotificationBellButton(
|
||||||
onPressed: () {},
|
iconColor: isDark
|
||||||
icon: Icon(
|
|
||||||
LucideIcons.bell,
|
|
||||||
color: isDark
|
|
||||||
? AppColors.textSecondaryDark
|
? AppColors.textSecondaryDark
|
||||||
: AppColors.textSecondaryLight,
|
: AppColors.textSecondaryLight,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
icon: Icon(
|
icon: AppIcon(
|
||||||
LucideIcons.settings,
|
glyph: AppIcons.settings,
|
||||||
color: isDark
|
color: isDark
|
||||||
? AppColors.textSecondaryDark
|
? AppColors.textSecondaryDark
|
||||||
: AppColors.textSecondaryLight,
|
: AppColors.textSecondaryLight,
|
||||||
@@ -207,8 +474,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
||||||
final next = schedule.nextPrayer;
|
final initialNext = _resolveNextPrayer(schedule, DateTime.now());
|
||||||
final time = next?.time ?? '--:--';
|
final fallbackPrayerName = initialNext?.name ?? 'Isya';
|
||||||
|
final fallbackTime = initialNext?.time ?? '--:--';
|
||||||
|
final isAdhanEnabled = _isAdhanEnabled;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -225,16 +494,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -20,
|
top: -6,
|
||||||
right: -20,
|
right: -4,
|
||||||
child: Container(
|
child: IgnorePointer(
|
||||||
width: 120,
|
child: Opacity(
|
||||||
height: 120,
|
opacity: 0.22,
|
||||||
decoration: BoxDecoration(
|
child: Image.asset(
|
||||||
shape: BoxShape.circle,
|
'assets/images/blob.png',
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -259,12 +532,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ValueListenableBuilder<String>(
|
AnimatedBuilder(
|
||||||
valueListenable: _nextPrayerName,
|
animation: Listenable.merge([_nextPrayerName, _nextPrayerTime]),
|
||||||
builder: (context, prayerName, _) {
|
builder: (context, _) {
|
||||||
final name = prayerName.isNotEmpty
|
final name = _nextPrayerName.value.isNotEmpty
|
||||||
? prayerName
|
? _nextPrayerName.value
|
||||||
: (next?.name ?? 'Isya');
|
: fallbackPrayerName;
|
||||||
|
final time = _nextPrayerTime.value.isNotEmpty
|
||||||
|
? _nextPrayerTime.value
|
||||||
|
: fallbackTime;
|
||||||
return Text(
|
return Text(
|
||||||
'$name — $time',
|
'$name — $time',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -291,13 +567,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// City name
|
// City name
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AppIcon(
|
||||||
|
glyph: AppIcons.location,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'📍 ${schedule.cityName}',
|
schedule.cityName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -313,12 +600,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.compass, size: 18, color: Colors.white),
|
AppIcon(
|
||||||
|
glyph: AppIcons.qibla,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Arah Kiblat',
|
'Arah Kiblat',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
@@ -329,19 +620,31 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
GestureDetector(
|
||||||
|
onTap: _toggleAdhanFromHero,
|
||||||
|
child: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
color: isAdhanEnabled
|
||||||
|
? Colors.white.withValues(alpha: 0.2)
|
||||||
|
: Colors.white.withValues(alpha: 0.12),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(
|
||||||
|
alpha: isAdhanEnabled ? 0.0 : 0.35,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
),
|
||||||
LucideIcons.volume2,
|
),
|
||||||
|
child: Icon(
|
||||||
|
isAdhanEnabled
|
||||||
|
? LucideIcons.volume2
|
||||||
|
: LucideIcons.volumeX,
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.onPrimary,
|
||||||
size: 22,
|
size: 22,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -372,25 +675,31 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
prayerTimesAsync.value?.isTomorrow == true
|
prayerTimesAsync.value?.isTomorrow == true
|
||||||
? 'Jadwal Sholat Besok'
|
? 'Jadwal Sholat Besok'
|
||||||
: 'Jadwal Sholat Hari Ini',
|
: 'Jadwal Sholat Hari Ini',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.w700)),
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary.withValues(alpha: 0.1),
|
color: AppColors.primary.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
|
prayerTimesAsync.value?.isTomorrow == true
|
||||||
|
? 'BESOK'
|
||||||
|
: 'HARI INI',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -407,10 +716,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: prayerTimesAsync.when(
|
child: prayerTimesAsync.when(
|
||||||
data: (schedule) {
|
data: (schedule) {
|
||||||
if (schedule == null) return const SizedBox();
|
if (schedule == null) return const SizedBox();
|
||||||
final prayers = schedule.prayerList.where(
|
final activePrayerName =
|
||||||
|
_resolveNextPrayer(schedule, DateTime.now())?.name;
|
||||||
|
final activePrayerKey = activePrayerName == null
|
||||||
|
? null
|
||||||
|
: '${schedule.date}:$activePrayerName';
|
||||||
|
final prayers = schedule.prayerList
|
||||||
|
.where(
|
||||||
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
||||||
.contains(p.name),
|
.contains(p.name),
|
||||||
).toList();
|
)
|
||||||
|
.toList();
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
controller: _prayerScrollController,
|
controller: _prayerScrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -419,14 +735,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final p = prayers[i];
|
final p = prayers[i];
|
||||||
final icon = _prayerIcon(p.name);
|
final icon = _prayerIcon(p.name);
|
||||||
|
final isActive = p.name == activePrayerName;
|
||||||
// Auto-scroll to active prayer on first build
|
// Auto-scroll to active prayer on first build
|
||||||
if (p.isActive && i > 0 && !_hasAutoScrolled) {
|
if (isActive &&
|
||||||
|
i > 0 &&
|
||||||
|
(!_hasAutoScrolled ||
|
||||||
|
_lastAutoScrollPrayerKey != activePrayerKey)) {
|
||||||
_hasAutoScrolled = true;
|
_hasAutoScrolled = true;
|
||||||
|
_lastAutoScrollPrayerKey = activePrayerKey;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_prayerScrollController.hasClients) {
|
if (_prayerScrollController.hasClients) {
|
||||||
final targetOffset = i * 124.0; // 112 width + 12 gap
|
final targetOffset = i * 124.0; // 112 width + 12 gap
|
||||||
_prayerScrollController.animateTo(
|
_prayerScrollController.animateTo(
|
||||||
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
|
targetOffset.clamp(0,
|
||||||
|
_prayerScrollController.position.maxScrollExtent),
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
);
|
);
|
||||||
@@ -437,15 +759,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
prayerName: p.name,
|
prayerName: p.name,
|
||||||
time: p.time,
|
time: p.time,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
isActive: p.isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () =>
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
const Center(child: CircularProgressIndicator()),
|
error: (_, __) => const Center(child: Text('Gagal memuat jadwal')),
|
||||||
error: (_, __) =>
|
|
||||||
const Center(child: Text('Gagal memuat jadwal')),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -561,8 +881,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_checklistPreviewItem(
|
_checklistPreviewItem(context, isDark, 'Sholat Fardhu',
|
||||||
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
'$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_checklistPreviewItem(
|
_checklistPreviewItem(
|
||||||
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
||||||
@@ -637,7 +957,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
// Reverse so today is on the far right (index 6)
|
// Reverse so today is on the far right (index 6)
|
||||||
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
final last7Days =
|
||||||
|
List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
||||||
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||||
|
|
||||||
final weekPoints = <int>[];
|
final weekPoints = <int>[];
|
||||||
@@ -675,6 +996,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: List.generate(7, (i) {
|
children: List.generate(7, (i) {
|
||||||
final val = weekPoints[i];
|
final val = weekPoints[i];
|
||||||
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
||||||
|
final labelColor = i == 6
|
||||||
|
? AppColors.primary
|
||||||
|
: (isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -683,31 +1009,47 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 80,
|
height: 96,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Container(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$val',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: labelColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 80 * ratio,
|
height: 80 * ratio,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: val > 0
|
color: val > 0
|
||||||
? AppColors.primary.withValues(
|
? AppColors.primary.withValues(
|
||||||
alpha: 0.2 + ratio * 0.8)
|
alpha: 0.2 + ratio * 0.8,
|
||||||
: AppColors.primary.withValues(alpha: 0.1),
|
)
|
||||||
|
: AppColors.primary
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
daysLabels[last7Days[i].weekday - 1], // Correct localized day
|
daysLabels[
|
||||||
|
last7Days[i].weekday - 1], // Correct localized day
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
|
fontWeight:
|
||||||
color: i == 6
|
i == 6 ? FontWeight.w800 : FontWeight.w600,
|
||||||
? AppColors.primary
|
color: labelColor,
|
||||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -722,6 +1064,78 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuickActions(BuildContext context, bool isDark) {
|
Widget _buildQuickActions(BuildContext context, bool isDark) {
|
||||||
|
final isSimpleMode = _isSimpleMode;
|
||||||
|
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: () {
|
||||||
|
if (isSimpleMode) {
|
||||||
|
context.go('/quran/1/murattal');
|
||||||
|
} else {
|
||||||
|
context.push('/tools/quran/1/murattal');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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: () {
|
||||||
|
if (isSimpleMode) {
|
||||||
|
context.push('/doa');
|
||||||
|
} else {
|
||||||
|
context.push('/tools/doa');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ToolCard(
|
||||||
|
icon: AppIcons.hadits,
|
||||||
|
title: "Hadits\nArba'in",
|
||||||
|
color: const Color(0xFF6C5CE7),
|
||||||
|
isDark: isDark,
|
||||||
|
onTap: () {
|
||||||
|
if (isSimpleMode) {
|
||||||
|
context.push('/hadits');
|
||||||
|
} else {
|
||||||
|
context.push('/tools/hadits');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ToolCard(
|
||||||
|
icon: AppIcons.quranEnrichment,
|
||||||
|
title: "Pendalaman\nAl-Qur'an",
|
||||||
|
color: const Color(0xFF00CEC9),
|
||||||
|
isDark: isDark,
|
||||||
|
onTap: () {
|
||||||
|
if (isSimpleMode) {
|
||||||
|
context.push('/quran/enrichment');
|
||||||
|
} else {
|
||||||
|
context.push('/tools/quran/enrichment');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -735,169 +1149,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
_buildQuickActionsGrid(cards),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [
|
children: [
|
||||||
Expanded(
|
for (final card in cards) SizedBox(width: cardWidth, child: card),
|
||||||
child: ToolCard(
|
|
||||||
icon: LucideIcons.bookOpen,
|
|
||||||
title: 'Al-Quran\nTerjemahan',
|
|
||||||
color: const Color(0xFF00B894),
|
|
||||||
isDark: isDark,
|
|
||||||
onTap: () {
|
|
||||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
|
|
||||||
if (isSimple) {
|
|
||||||
context.go('/quran');
|
|
||||||
} else {
|
|
||||||
context.push('/tools/quran');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ToolCard(
|
|
||||||
icon: LucideIcons.headphones,
|
|
||||||
title: 'Quran\nMurattal',
|
|
||||||
color: const Color(0xFF7B61FF),
|
|
||||||
isDark: isDark,
|
|
||||||
onTap: () {
|
|
||||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
|
|
||||||
if (isSimple) {
|
|
||||||
context.go('/quran/1/murattal');
|
|
||||||
} else {
|
|
||||||
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: () {
|
|
||||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
|
|
||||||
if (isSimple) {
|
|
||||||
context.push('/qibla');
|
|
||||||
} else {
|
|
||||||
context.push('/tools/qibla');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ToolCard(
|
|
||||||
icon: LucideIcons.sparkles,
|
|
||||||
title: 'Tasbih\nDigital',
|
|
||||||
color: AppColors.primary,
|
|
||||||
isDark: isDark,
|
|
||||||
onTap: () {
|
|
||||||
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
|
|
||||||
if (isSimple) {
|
|
||||||
context.go('/dzikir');
|
|
||||||
} else {
|
|
||||||
context.push('/tools/dzikir');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAyatHariIni(BuildContext context, bool isDark) {
|
Widget _buildAyatHariIni(BuildContext context, bool isDark) {
|
||||||
return FutureBuilder<Map<String, dynamic>?>(
|
return const AyatTodayCard(
|
||||||
future: EQuranService.instance.getDailyAyat(),
|
headerText: 'AYAT HARI INI',
|
||||||
builder: (context, snapshot) {
|
headerStyle: TextStyle(
|
||||||
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: [
|
|
||||||
const Text(
|
|
||||||
'AYAT HARI INI',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
color: AppColors.sage,
|
color: AppColors.sage,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Icon(LucideIcons.quote,
|
|
||||||
size: 20,
|
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Text(
|
|
||||||
data['teksArab'] ?? '',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'Amiri',
|
|
||||||
fontSize: 24,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
277
lib/features/doa/presentation/doa_screen.dart
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/arabic_text.dart';
|
||||||
|
import '../../../data/local/hive_boxes.dart';
|
||||||
|
import '../../../data/local/models/app_settings.dart';
|
||||||
|
import '../../../data/services/muslim_api_service.dart';
|
||||||
|
|
||||||
|
class DoaScreen extends StatefulWidget {
|
||||||
|
final bool isSimpleModeTab;
|
||||||
|
const DoaScreen({super.key, this.isSimpleModeTab = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DoaScreen> createState() => _DoaScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoaScreenState extends State<DoaScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<Map<String, dynamic>> _allDoa = [];
|
||||||
|
List<Map<String, dynamic>> _filteredDoa = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDoa();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDoa() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await MuslimApiService.instance.getDoaList(strict: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_allDoa = data;
|
||||||
|
_filteredDoa = data;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_allDoa = [];
|
||||||
|
_filteredDoa = [];
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Gagal memuat doa dari server';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String value) {
|
||||||
|
final q = value.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) {
|
||||||
|
setState(() => _filteredDoa = _allDoa);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_filteredDoa = _allDoa.where((item) {
|
||||||
|
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||||
|
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||||
|
return title.contains(q) || indo.contains(q);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showArabicFontSettings() {
|
||||||
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
final settings = settingsBox.get('default') ?? AppSettings();
|
||||||
|
if (!settings.isInBox) {
|
||||||
|
settingsBox.put('default', settings);
|
||||||
|
}
|
||||||
|
double arabicFontSize = settings.arabicFontSize;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Pengaturan Tampilan',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Ukuran Font Arab'),
|
||||||
|
Slider(
|
||||||
|
value: arabicFontSize,
|
||||||
|
min: 16,
|
||||||
|
max: 40,
|
||||||
|
divisions: 12,
|
||||||
|
label: '${arabicFontSize.round()}pt',
|
||||||
|
activeColor: AppColors.primary,
|
||||||
|
onChanged: (value) {
|
||||||
|
setModalState(() => arabicFontSize = value);
|
||||||
|
settings.arabicFontSize = value;
|
||||||
|
if (settings.isInBox) {
|
||||||
|
settings.save();
|
||||||
|
} else {
|
||||||
|
settingsBox.put('default', settings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||||
|
title: const Text('Kumpulan Doa'),
|
||||||
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _loadDoa,
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
tooltip: 'Muat ulang',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _showArabicFontSettings,
|
||||||
|
icon: const Icon(LucideIcons.settings2),
|
||||||
|
tooltip: 'Pengaturan tampilan',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
bottom: !widget.isSimpleModeTab,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Cari judul atau isi doa...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _filteredDoa.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'Doa tidak ditemukan',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
itemCount: _filteredDoa.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _filteredDoa[index];
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.primary
|
||||||
|
.withValues(alpha: 0.1)
|
||||||
|
: AppColors.cream,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item['judul']?.toString() ?? '-',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ArabicText(
|
||||||
|
item['arab']?.toString() ?? '',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
baseFontSize: 24,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item['indo']?.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
height: 1.5,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((item['source']
|
||||||
|
?.toString()
|
||||||
|
.isNotEmpty ??
|
||||||
|
false)) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Sumber: ${item['source']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
lib/features/hadits/presentation/hadits_screen.dart
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/arabic_text.dart';
|
||||||
|
import '../../../data/local/hive_boxes.dart';
|
||||||
|
import '../../../data/local/models/app_settings.dart';
|
||||||
|
import '../../../data/services/muslim_api_service.dart';
|
||||||
|
|
||||||
|
class HaditsScreen extends StatefulWidget {
|
||||||
|
final bool isSimpleModeTab;
|
||||||
|
const HaditsScreen({super.key, this.isSimpleModeTab = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HaditsScreen> createState() => _HaditsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HaditsScreenState extends State<HaditsScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<Map<String, dynamic>> _allHadits = [];
|
||||||
|
List<Map<String, dynamic>> _filteredHadits = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHadits();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHadits() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await MuslimApiService.instance.getHaditsList(strict: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
data.sort((a, b) {
|
||||||
|
final aa = (a['no'] as num?)?.toInt() ?? 0;
|
||||||
|
final bb = (b['no'] as num?)?.toInt() ?? 0;
|
||||||
|
return aa.compareTo(bb);
|
||||||
|
});
|
||||||
|
setState(() {
|
||||||
|
_allHadits = data;
|
||||||
|
_filteredHadits = data;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_allHadits = [];
|
||||||
|
_filteredHadits = [];
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Gagal memuat hadits dari server';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String value) {
|
||||||
|
final q = value.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) {
|
||||||
|
setState(() => _filteredHadits = _allHadits);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_filteredHadits = _allHadits.where((item) {
|
||||||
|
final title = item['judul']?.toString().toLowerCase() ?? '';
|
||||||
|
final indo = item['indo']?.toString().toLowerCase() ?? '';
|
||||||
|
return title.contains(q) || indo.contains(q);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showArabicFontSettings() {
|
||||||
|
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||||
|
final settings = settingsBox.get('default') ?? AppSettings();
|
||||||
|
if (!settings.isInBox) {
|
||||||
|
settingsBox.put('default', settings);
|
||||||
|
}
|
||||||
|
double arabicFontSize = settings.arabicFontSize;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Pengaturan Tampilan',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Ukuran Font Arab'),
|
||||||
|
Slider(
|
||||||
|
value: arabicFontSize,
|
||||||
|
min: 16,
|
||||||
|
max: 40,
|
||||||
|
divisions: 12,
|
||||||
|
label: '${arabicFontSize.round()}pt',
|
||||||
|
activeColor: AppColors.primary,
|
||||||
|
onChanged: (value) {
|
||||||
|
setModalState(() => arabicFontSize = value);
|
||||||
|
settings.arabicFontSize = value;
|
||||||
|
if (settings.isInBox) {
|
||||||
|
settings.save();
|
||||||
|
} else {
|
||||||
|
settingsBox.put('default', settings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: !widget.isSimpleModeTab,
|
||||||
|
title: const Text("Hadits Arba'in"),
|
||||||
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _loadHadits,
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
tooltip: 'Muat ulang',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _showArabicFontSettings,
|
||||||
|
icon: const Icon(LucideIcons.settings2),
|
||||||
|
tooltip: 'Pengaturan tampilan',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
bottom: !widget.isSimpleModeTab,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Cari judul atau isi hadits...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _filteredHadits.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'Hadits tidak ditemukan',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
itemCount: _filteredHadits.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _filteredHadits[index];
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? AppColors.primary
|
||||||
|
.withValues(alpha: 0.1)
|
||||||
|
: AppColors.cream,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary
|
||||||
|
.withValues(alpha: 0.12),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${item['no'] ?? '-'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item['judul']?.toString() ?? '-',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ArabicText(
|
||||||
|
item['arab']?.toString() ?? '',
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
baseFontSize: 24,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item['indo']?.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
height: 1.5,
|
||||||
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../app/theme/app_colors.dart';
|
import '../../../app/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/notification_bell_button.dart';
|
||||||
import '../../../data/local/hive_boxes.dart';
|
import '../../../data/local/hive_boxes.dart';
|
||||||
import '../../../data/local/models/app_settings.dart';
|
import '../../../data/local/models/app_settings.dart';
|
||||||
import '../../../data/services/prayer_service.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) {
|
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||||
final selected = _months[_selectedMonthIndex];
|
final selected = _months[_selectedMonthIndex];
|
||||||
final daysInMonth =
|
final daysInMonth = DateTime(selected.year, selected.month + 1, 0).day;
|
||||||
DateTime(selected.year, selected.month + 1, 0).day;
|
|
||||||
final rows = <_DayRow>[];
|
final rows = <_DayRow>[];
|
||||||
|
|
||||||
for (int d = 1; d <= daysInMonth; d++) {
|
for (int d = 1; d <= daysInMonth; d++) {
|
||||||
@@ -102,7 +102,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) => AlertDialog(
|
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'),
|
title: const Text('Cari Kota/Kabupaten'),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.85,
|
width: MediaQuery.of(context).size.width * 0.85,
|
||||||
@@ -135,12 +136,14 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
if (val.trim().length < 3) return;
|
if (val.trim().length < 3) return;
|
||||||
|
|
||||||
if (debounce?.isActive ?? false) debounce!.cancel();
|
if (debounce?.isActive ?? false) debounce!.cancel();
|
||||||
debounce = Timer(const Duration(milliseconds: 500), () async {
|
debounce =
|
||||||
|
Timer(const Duration(milliseconds: 500), () async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setDialogState(() => isSearching = true);
|
setDialogState(() => isSearching = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await MyQuranSholatService.instance.searchCity(val.trim());
|
final res = await MyQuranSholatService.instance
|
||||||
|
.searchCity(val.trim());
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
results = res;
|
results = res;
|
||||||
@@ -175,7 +178,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
if (isSearching)
|
if (isSearching)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (results.isEmpty)
|
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
|
else
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
@@ -224,9 +228,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
|
const tableBottomSpacing = 28.0;
|
||||||
|
|
||||||
final selectedMonth = _months[_selectedMonthIndex];
|
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 cityNameAsync = ref.watch(cityNameProvider);
|
||||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||||
|
|
||||||
@@ -235,10 +241,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
title: const Text('Kalender Sholat'),
|
title: const Text('Kalender Sholat'),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
const NotificationBellButton(),
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(LucideIcons.bell),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
icon: const Icon(LucideIcons.settings),
|
icon: const Icon(LucideIcons.settings),
|
||||||
@@ -266,7 +269,9 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primary
|
? AppColors.primary
|
||||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
: (isDark
|
||||||
|
? AppColors.surfaceDark
|
||||||
|
: AppColors.surfaceLight),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? null
|
? null
|
||||||
@@ -306,7 +311,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
color:
|
||||||
|
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isDark
|
color: isDark
|
||||||
@@ -378,7 +384,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
data: (apiData) {
|
data: (apiData) {
|
||||||
final rows = _createRows(apiData);
|
final rows = _createRows(apiData);
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
tableBottomSpacing,
|
||||||
|
),
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -453,7 +464,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
error: (_, __) {
|
error: (_, __) {
|
||||||
final rows = _createRows(null); // fallback
|
final rows = _createRows(null); // fallback
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
tableBottomSpacing,
|
||||||
|
),
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -536,7 +552,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import 'package:hive_flutter/hive_flutter.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../../app/theme/app_colors.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/hive_boxes.dart';
|
||||||
import '../../../data/local/models/app_settings.dart';
|
import '../../../data/local/models/app_settings.dart';
|
||||||
import '../../../data/local/models/daily_worship_log.dart';
|
import '../../../data/local/models/daily_worship_log.dart';
|
||||||
import '../../../data/local/models/checklist_item.dart';
|
|
||||||
|
|
||||||
class LaporanScreen extends ConsumerStatefulWidget {
|
class LaporanScreen extends ConsumerStatefulWidget {
|
||||||
const LaporanScreen({super.key});
|
const LaporanScreen({super.key});
|
||||||
@@ -80,34 +79,46 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
|
|
||||||
// Fardhu
|
// Fardhu
|
||||||
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
|
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
|
||||||
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
|
int completedFardhu =
|
||||||
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
|
log.shalatLogs.values.where((l) => l.completed).length;
|
||||||
|
completionCounts['fardhu'] =
|
||||||
|
(completionCounts['fardhu'] ?? 0) + completedFardhu;
|
||||||
|
|
||||||
// Rawatib
|
// Rawatib
|
||||||
int rawatibTotal = 0;
|
int rawatibTotal = 0;
|
||||||
int rawatibCompleted = 0;
|
int rawatibCompleted = 0;
|
||||||
for (var sLog in log.shalatLogs.values) {
|
for (var sLog in log.shalatLogs.values) {
|
||||||
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
|
if (sLog.qabliyah != null) {
|
||||||
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
|
rawatibTotal++;
|
||||||
|
if (sLog.qabliyah!) rawatibCompleted++;
|
||||||
|
}
|
||||||
|
if (sLog.badiyah != null) {
|
||||||
|
rawatibTotal++;
|
||||||
|
if (sLog.badiyah!) rawatibCompleted++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rawatibTotal > 0) {
|
if (rawatibTotal > 0) {
|
||||||
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
||||||
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
completionCounts['rawatib'] =
|
||||||
|
(completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tilawah
|
// Tilawah
|
||||||
if (log.tilawahLog != null) {
|
if (log.tilawahLog != null) {
|
||||||
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
||||||
if (log.tilawahLog!.isCompleted) {
|
if (log.tilawahLog!.isCompleted) {
|
||||||
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
|
completionCounts['tilawah'] =
|
||||||
|
(completionCounts['tilawah'] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dzikir
|
// Dzikir
|
||||||
if (log.dzikirLog != null) {
|
if (log.dzikirLog != null) {
|
||||||
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
||||||
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
int dCompleted =
|
||||||
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
|
(log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
||||||
|
completionCounts['dzikir'] =
|
||||||
|
(completionCounts['dzikir'] ?? 0) + dCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puasa
|
// Puasa
|
||||||
@@ -180,10 +191,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
title: const Text('Riwayat Ibadah'),
|
title: const Text('Riwayat Ibadah'),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
const NotificationBellButton(),
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(LucideIcons.bell),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
icon: const Icon(LucideIcons.settings),
|
icon: const Icon(LucideIcons.settings),
|
||||||
@@ -204,10 +212,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
title: const Text('Laporan Kualitas Ibadah'),
|
title: const Text('Laporan Kualitas Ibadah'),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
const NotificationBellButton(),
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(LucideIcons.bell),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
icon: const Icon(LucideIcons.settings),
|
icon: const Icon(LucideIcons.settings),
|
||||||
@@ -253,7 +258,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
|
_buildWeeklyView(
|
||||||
|
context, isDark, weekData, avgPercent, insights),
|
||||||
_buildComingSoon(context, 'Bulanan'),
|
_buildComingSoon(context, 'Bulanan'),
|
||||||
_buildComingSoon(context, 'Tahunan'),
|
_buildComingSoon(context, 'Tahunan'),
|
||||||
],
|
],
|
||||||
@@ -332,10 +338,12 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// ── Bar Chart ──
|
// ── Bar Chart ──
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 140,
|
height: 162,
|
||||||
child: Builder(
|
child: Builder(builder: (context) {
|
||||||
builder: (context) {
|
final maxPts = weekData
|
||||||
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
|
.map((d) => d.value)
|
||||||
|
.fold<double>(0.0, (a, b) => a > b ? a : b)
|
||||||
|
.clamp(50.0, 300.0);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -347,6 +355,19 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
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),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -354,8 +375,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: d.isToday
|
color: d.isToday
|
||||||
? AppColors.primary
|
? AppColors.primary
|
||||||
: AppColors.primary
|
: AppColors.primary.withValues(
|
||||||
.withValues(alpha: 0.3 + ratio * 0.4),
|
alpha: 0.3 + ratio * 0.4),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -381,8 +402,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -587,9 +607,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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 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
|
// Build summary text
|
||||||
final List<String> finished = [];
|
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 (fardhuCount > 0) finished.add('$fardhuCount Fardhu');
|
||||||
if (log.tilawahLog?.isCompleted == true) finished.add('Tilawah');
|
if (log.tilawahLog?.isCompleted == true) finished.add('Tilawah');
|
||||||
if (log.dzikirLog != null) {
|
if (log.dzikirLog != null) {
|
||||||
@@ -635,7 +658,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
color: AppColors.primary.withValues(alpha: 0.15),
|
color: AppColors.primary.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.checkCircle2, color: AppColors.primary),
|
child: const Icon(LucideIcons.checkCircle2,
|
||||||
|
color: AppColors.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -643,7 +667,10 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
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(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -651,10 +678,14 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
finished.isNotEmpty ? finished.join(' • ') : 'Belum ada aktivitas',
|
finished.isNotEmpty
|
||||||
|
? finished.join(' • ')
|
||||||
|
: 'Belum ada aktivitas',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
color: isDark
|
||||||
|
? AppColors.textSecondaryDark
|
||||||
|
: AppColors.textSecondaryLight,
|
||||||
height: 1.4,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||