Polish navigation, Quran flows, and sharing UX

This commit is contained in:
Dwindi Ramadhana
2026-03-18 00:07:10 +07:00
parent a049129a35
commit 2d09b5b356
59 changed files with 11835 additions and 3184 deletions

View File

@@ -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")
}
} }
} }
} }

View File

@@ -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

View File

@@ -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) {

View File

@@ -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())
}
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/images/blob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

248
docs/notification-plan.md Normal file
View File

@@ -0,0 +1,248 @@
# Notification Plan (Alerts + Inbox)
Last updated: 2026-03-16
Owner: Product + Mobile
Scope: `jamshalat_diary` (Flutter)
## Implementation Status (2026-03-17)
- Phase 1: Implemented.
- Phase 2: Implemented (Notification Center + unread badge + inbox persistence).
- Phase 3: Implemented (daily checklist reminder scheduling, streak-risk messages, weekly summary, quiet-hours and daily cap preferences).
- Phase 4: Partially implemented via remote-content sync and remote-push ingestion bridge.
- External pending for full Phase 4: SDK/provider wiring (FCM/APNs credentials, token registration, backend push dispatch).
## 1. Problem Statement
Current app has mixed meaning for "notification":
- Device push alert (time-sensitive, appears in system drawer)
- In-app message feed (read-later information)
This causes unclear UX around the bell icon and settings.
## 2. Product Model
Split into two clear products:
1. **Alerts** (`Pemberitahuan`)
- Time-sensitive pushes to OS notification drawer.
- Can ring/vibrate.
- Expire quickly.
2. **Inbox** (`Pesan`)
- In-app list of messages user can read later.
- Has read/unread state.
- No mandatory sound.
Default rule:
- **Push only**: urgent + expiring event.
- **Inbox only**: informational/non-urgent event.
- **Push + Inbox**: important event that also needs follow-up context.
## 3. Terminology (User-Facing)
- Bell icon label/context: `Pemberitahuan`
- Notification center tabs:
- `Alarm` (alert history + system-triggered items)
- `Pesan` (inbox/read-later)
## 4. Event Policy Matrix
| Event | Push Drawer | Inbox | Sound | Expiry |
|---|---|---|---|---|
| Adzan time entered | Yes | Optional (off by default) | Yes | At end of prayer window |
| Iqamah reminder | Yes | No | Yes | At iqamah time |
| Next prayer in 10 minutes | Optional | No | Soft | At prayer start |
| Daily checklist reminder | Yes (if enabled) | No | Optional | End of day |
| Streak at risk (Tilawah/Dzikir) | Optional | Yes | No | End of day |
| Weekly worship summary | No | Yes | No | 7 days |
| Permission blocked (notif/exact alarm/location) | Yes | Yes | No | When resolved |
| Schedule/location stale | Yes | Yes | No | When resolved |
| New app content (Doa/Hadits) | No | Yes | No | 30 days |
## 5. Architecture
## 5.1 Channels
- **Local scheduled alerts** (already started in app): Adzan + Iqamah.
- **In-app inbox storage** (new): persisted read/unread messages.
- **Remote push** (future): FCM/APNs for server-driven campaigns/events.
## 5.2 Data Sources
- Prayer schedules: `MyQuranSholatService`.
- User prefs: `AppSettings` + new notification preference model.
- Device status: notification permission, exact alarm permission, location service.
## 5.3 Decision Engine
Input:
- event type
- urgency
- TTL
- user preferences
- cooldown/frequency cap
Output:
- deliver to `push`, `inbox`, or both.
Pseudo rule:
```text
if event.isTimeCritical && event.ttlShort:
push
if event.needsFollowUp || event.referenceContent:
inbox
if event.push && event.inboxPolicy == mirror:
push + inbox
```
## 6. Data Model
## 6.1 Inbox Item (Hive)
Suggested new box: `notification_inbox`
```json
{
"id": "uuid",
"type": "streak_risk | summary | system | content | prayer",
"title": "string",
"body": "string",
"createdAt": "iso8601",
"expiresAt": "iso8601|null",
"readAt": "iso8601|null",
"isPinned": false,
"deeplink": "/route/path",
"meta": {
"cityId": "string|null",
"prayerKey": "fajr|dhuhr|asr|maghrib|isha|null",
"date": "yyyy-MM-dd|null"
},
"source": "local|remote",
"dedupeKey": "string"
}
```
## 6.2 Preferences
Use existing `AppSettings` for:
- `adhanEnabled` (per prayer)
- `iqamahOffset` (per prayer)
Add new fields (either in `AppSettings` or separate box):
- `alertsEnabled` (global)
- `inboxEnabled` (global)
- `streakRiskEnabled`
- `dailyChecklistReminderEnabled`
- `weeklySummaryEnabled`
- `quietHoursStart` / `quietHoursEnd`
- `maxNonPrayerPushPerDay` (default 2)
## 6.3 Badge Source
- Bell badge count = unread inbox count only.
- Do not include "already fired system push" count.
## 7. Local Notification Spec (Current + Next)
## 7.1 Channels
- `adhan_channel`: max importance, sound on.
- `iqamah_channel`: high importance, sound on.
- Later:
- `habit_channel` (streak/checklist)
- `system_channel` (permission/location)
## 7.2 Permission Strategy
- Request `POST_NOTIFICATIONS` and exact alarm capability only when needed.
- If denied:
- create Inbox warning item
- show non-blocking UI notice
## 7.3 Scheduling Rules
- Keep 2-day rolling window (today + tomorrow).
- Resync on:
- app start
- city change
- prayer settings change
- adzan toggle change
- daily boundary change (00:05 local)
- Deduplicate by key: `cityId + date + prayer + kind(adhan/iqamah)`.
## 8. UX Specification
## 8.1 Bell Icon
- Tap action: open Notification Center.
- Badge: unread inbox count.
- Long-press (optional): quick actions
- toggle `Alarm Sholat`
- open Iqamah settings
- sync now
## 8.2 Notification Center Screen
Tabs:
- `Alarm`
- recent fired alerts (read-only timeline, optional v2)
- `Pesan`
- unread/read list with filters (`Semua`, `Belum Dibaca`, `Sistem`)
Item actions:
- Tap: open deeplink target.
- Swipe: mark read/unread.
- Optional: pin important item.
## 8.3 Copy Guidelines
- Urgent: concise, action-first.
- Inbox: context-rich but short (max ~120 chars body preview).
- Prayer push examples:
- `Adzan • Subuh`
- `Waktu sholat Subuh telah masuk.`
## 9. Deep Link Map
- Permission blocked -> `/settings` (notifications section)
- Location disabled -> `/settings` or location setup section
- Streak risk Tilawah -> `/quran`
- Streak risk Dzikir -> `/tools/dzikir`
- Weekly summary -> `/laporan`
- Prayer event details -> `/` (Beranda)
## 10. Analytics
Track:
- `notif_push_scheduled`
- `notif_push_fired`
- `notif_push_opened`
- `notif_inbox_created`
- `notif_inbox_opened`
- `notif_mark_read`
- `notif_settings_changed`
- `notif_permission_denied`
Dimensions:
- `event_type`
- `channel` (`push|inbox|both`)
- `city_id`
- `simple_mode`
## 11. Rollout Plan
## Phase 1 (Now)
- Stabilize prayer alerts (Adzan + Iqamah) local scheduling.
- Working sound toggle in hero card and settings.
- Permission and exact-alarm checks.
## Phase 2
- Build Notification Center page + unread badge.
- Add inbox persistence and read/unread actions.
- Add system warning messages to inbox.
## Phase 3
- Add non-prayer reminders (checklist, streak risk, weekly summary).
- Add cooldown/frequency cap and quiet hours.
## Phase 4
- Remote push integration (FCM/APNs).
- Server-defined campaigns/content updates.
## 12. Acceptance Criteria
- Toggling adzan off removes all pending adzan/iqamah notifications.
- Toggling adzan on schedules valid upcoming notifications for enabled prayers.
- Changing iqamah minutes updates future scheduled iqamah alerts immediately.
- Bell opens Notification Center (after Phase 2).
- Unread badge count reflects inbox unread only.
- No duplicate notifications for same prayer/time/city.
## 13. QA Checklist
- Android 13/14: permission denied -> graceful fallback.
- Exact alarm disabled -> user warning path works.
- Timezone changes -> schedule recalculates correctly.
- Day rollover -> next-day notifications still present.
- City changes -> old city schedules removed; new city schedules added.
- Device reboot (future): optional reschedule receiver if needed.
## 14. Open Decisions
1. Should adzan events be mirrored into inbox by default?
2. Should quiet hours suppress non-prayer push only, or all push except adzan?
3. Should `Alarm` tab show only fired events or also pending schedule preview?

173
hugeicons-migration-spec.md Normal file
View 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

View File

@@ -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"]];
} }

View File

@@ -53,6 +53,10 @@
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@@ -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(

View 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);
}

View File

@@ -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';
@@ -19,6 +21,7 @@ import '../features/quran/presentation/quran_reading_screen.dart';
import '../features/quran/presentation/quran_murattal_screen.dart'; import '../features/quran/presentation/quran_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/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).
@@ -33,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(
@@ -50,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(
@@ -97,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(
@@ -107,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,
@@ -139,7 +153,8 @@ final GoRouter appRouter = GoRouter(
// 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(
@@ -148,18 +163,24 @@ final GoRouter appRouter = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: 'enrichment', path: 'enrichment',
builder: (context, state) => const QuranEnrichmentScreen(), 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(
@@ -168,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,
@@ -187,11 +209,17 @@ final GoRouter appRouter = GoRouter(
), ),
GoRoute( GoRoute(
path: '/hadits', path: '/hadits',
builder: (context, state) => const HaditsScreen(isSimpleModeTab: true), 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,
@@ -201,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();
@@ -214,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;
@@ -243,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 {
@@ -270,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),
), ),
),
); );
}, },
); );

View File

@@ -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,
); );
} }

View File

@@ -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.w400, fontWeight: FontWeight.w400,
height: 2.2, height: 2.0,
); );
} }

View File

@@ -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,
); );
} }

View 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();
}

View 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,
),
);
},
);
}
}

View 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;
}
}

View 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'),
),
],
),
],
),
);
}
}

View File

@@ -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;
}
}

View 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),
],
),
);
},
);
}
}

View File

@@ -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,

View File

@@ -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),
]; ];

View File

@@ -77,6 +77,33 @@ class AppSettings extends HiveObject {
@HiveField(23) @HiveField(23)
bool dzikirHapticOnCount; 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 = '',
@@ -102,6 +129,15 @@ class AppSettings extends HiveObject {
this.dzikirCounterButtonPosition = 'bottomPill', this.dzikirCounterButtonPosition = 'bottomPill',
this.dzikirAutoAdvance = true, this.dzikirAutoAdvance = true,
this.dzikirHapticOnCount = 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,

View File

@@ -20,34 +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', dzikirDisplayMode:
dzikirCounterButtonPosition: fields.containsKey(21) ? fields[21] as String? ?? 'bottomPill' : 'bottomPill', fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
dzikirAutoAdvance: fields.containsKey(22) ? fields[22] as bool? ?? true : true, dzikirCounterButtonPosition: fields.containsKey(21)
dzikirHapticOnCount: fields.containsKey(23) ? fields[23] as bool? ?? true : true, ? 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(24) ..writeByte(33)
..writeByte(0) ..writeByte(0)
..write(obj.userName) ..write(obj.userName)
..writeByte(1) ..writeByte(1)
@@ -95,7 +126,25 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..writeByte(22) ..writeByte(22)
..write(obj.dzikirAutoAdvance) ..write(obj.dzikirAutoAdvance)
..writeByte(23) ..writeByte(23)
..write(obj.dzikirHapticOnCount); ..write(obj.dzikirHapticOnCount)
..writeByte(24)
..write(obj.alertsEnabled)
..writeByte(25)
..write(obj.inboxEnabled)
..writeByte(26)
..write(obj.streakRiskEnabled)
..writeByte(27)
..write(obj.dailyChecklistReminderEnabled)
..writeByte(28)
..write(obj.weeklySummaryEnabled)
..writeByte(29)
..write(obj.quietHoursStart)
..writeByte(30)
..write(obj.quietHoursEnd)
..writeByte(31)
..write(obj.maxNonPrayerPushPerDay)
..writeByte(32)
..write(obj.mirrorAdzanToInbox);
} }
@override @override

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class MuslimApiException implements Exception { class MuslimApiException implements Exception {
@@ -138,7 +139,7 @@ class MuslimApiService {
} }
Map<String, String> _normalizeAudioMap(dynamic audioValue) { Map<String, String> _normalizeAudioMap(dynamic audioValue) {
final audioUrl = _asString(audioValue); final audioUrl = _extractAudioUrl(audioValue);
if (audioUrl.isEmpty) return {}; if (audioUrl.isEmpty) return {};
return { return {
'01': audioUrl, '01': audioUrl,
@@ -150,6 +151,59 @@ class MuslimApiService {
}; };
} }
String _extractAudioUrl(dynamic value) {
if (value == null) return '';
if (value is String) return value.trim();
if (value is Map) {
final direct = _asString(value['url']).trim();
if (direct.isNotEmpty) return direct;
final src = _asString(value['src']).trim();
if (src.isNotEmpty) return src;
final audio = _asString(value['audio']).trim();
if (audio.isNotEmpty) return audio;
}
return '';
}
String _normalizeQariKey(dynamic rawKey) {
if (rawKey == null) return '';
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
final text = rawKey.toString().trim();
if (text.isEmpty) return '';
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isNotEmpty) {
final parsed = int.tryParse(digits);
if (parsed != null) return parsed.toString().padLeft(2, '0');
}
return text;
}
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
if (audioValue is Map) {
final normalized = <String, String>{};
audioValue.forEach((rawKey, rawValue) {
final key = _normalizeQariKey(rawKey);
final url = _extractAudioUrl(rawValue);
if (key.isNotEmpty && url.isNotEmpty) {
normalized[key] = url;
}
});
if (normalized.isNotEmpty) {
final fallbackUrl = normalized.values.first;
for (final qariId in qariNames.keys) {
normalized.putIfAbsent(qariId, () => fallbackUrl);
}
return normalized;
}
}
return _normalizeAudioMap(audioValue);
}
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) { Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
final number = _asInt(item['number']); final number = _asInt(item['number']);
return { return {
@@ -165,20 +219,12 @@ class MuslimApiService {
} }
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) { Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
final audio = _asString(item['audio']);
return { return {
'nomorAyat': _asInt(item['ayah']), 'nomorAyat': _asInt(item['ayah']),
'teksArab': _asString(item['arab']), 'teksArab': _asString(item['arab']),
'teksLatin': _asString(item['latin']), 'teksLatin': _asString(item['latin']),
'teksIndonesia': _asString(item['text']), 'teksIndonesia': _asString(item['text']),
'audio': { 'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
'01': audio,
'02': audio,
'03': audio,
'04': audio,
'05': audio,
'06': audio,
},
'juz': _asInt(item['juz']), 'juz': _asInt(item['juz']),
'page': _asInt(item['page']), 'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']), 'hizb': _asInt(item['hizb']),
@@ -194,10 +240,8 @@ class MuslimApiService {
if (_surahListCache != null) return _surahListCache!; if (_surahListCache != null) return _surahListCache!;
final raw = await _getData('/v1/quran/surah'); final raw = await _getData('/v1/quran/surah');
if (raw is! List) return []; if (raw is! List) return [];
_surahListCache = raw _surahListCache =
.whereType<Map<String, dynamic>>() raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
.map(_mapSurahSummary)
.toList();
return _surahListCache!; return _surahListCache!;
} }
@@ -219,10 +263,8 @@ class MuslimApiService {
return null; return null;
} }
final mappedAyah = rawAyah final mappedAyah =
.whereType<Map<String, dynamic>>() rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
.map(_mapAyah)
.toList();
final mapped = { final mapped = {
...summary, ...summary,
@@ -257,11 +299,58 @@ class MuslimApiService {
} }
} }
Future<List<Map<String, dynamic>>> getWordByWord(int surahId, int ayahId) async { Future<Map<String, dynamic>?> getRandomAyat({
int? excludeSurahNumber,
int? excludeAyahNumber,
}) async {
try {
final allAyah = await getAllAyah();
if (allAyah.isEmpty) return null;
final surahs = await getAllSurahs();
if (surahs.isEmpty) return null;
final surahNames = <int, String>{
for (final surah in surahs)
_asInt(surah['nomor']): _asString(surah['namaLatin']),
};
final filtered = allAyah.where((ayah) {
final surahNumber = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
final isExcluded = excludeSurahNumber != null &&
excludeAyahNumber != null &&
surahNumber == excludeSurahNumber &&
ayahNumber == excludeAyahNumber;
if (isExcluded) return false;
return _asString(ayah['arab']).trim().isNotEmpty &&
_asString(ayah['text']).trim().isNotEmpty;
}).toList();
final candidates = filtered.isNotEmpty ? filtered : allAyah;
final picked = candidates[Random().nextInt(candidates.length)];
final surahNumber = _asInt(picked['surah']);
return {
'surahName': surahNames[surahNumber] ?? '',
'nomorSurah': surahNumber,
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
'teksArab': _asString(picked['arab']),
'teksIndonesia': _asString(picked['text']),
};
} catch (_) {
return null;
}
}
Future<List<Map<String, dynamic>>> getWordByWord(
int surahId, int ayahId) async {
final key = '$surahId:$ayahId'; final key = '$surahId:$ayahId';
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!; if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
final raw = await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId'); final raw =
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
if (raw is! List) return []; if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) { final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
@@ -342,7 +431,8 @@ class MuslimApiService {
}); });
} }
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int)); result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result; return result;
} }
@@ -386,7 +476,8 @@ class MuslimApiService {
}); });
} }
result.sort((a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int)); result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result; return result;
} }
@@ -449,12 +540,17 @@ class MuslimApiService {
if (q.isEmpty) return []; if (q.isEmpty) return [];
final allAyah = await getAllAyah(); final allAyah = await getAllAyah();
final results = allAyah.where((item) { final results = allAyah
.where((item) {
final text = _asString(item['text']).toLowerCase(); final text = _asString(item['text']).toLowerCase();
final latin = _asString(item['latin']).toLowerCase(); final latin = _asString(item['latin']).toLowerCase();
final arab = _asString(item['arab']); final arab = _asString(item['arab']);
return text.contains(q) || latin.contains(q) || arab.contains(query.trim()); return text.contains(q) ||
}).take(50).toList(); latin.contains(q) ||
arab.contains(query.trim());
})
.take(50)
.toList();
return results; return results;
} }
@@ -478,9 +574,8 @@ class MuslimApiService {
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async { Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
if (_doaCache != null) return _doaCache!; if (_doaCache != null) return _doaCache!;
final raw = strict final raw =
? await _getDataOrThrow('/v1/doa') strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
: await _getData('/v1/doa');
if (raw is! List) { if (raw is! List) {
if (strict) { if (strict) {
throw const MuslimApiException('Invalid doa payload'); throw const MuslimApiException('Invalid doa payload');
@@ -500,7 +595,8 @@ class MuslimApiService {
return _doaCache!; return _doaCache!;
} }
Future<List<Map<String, dynamic>>> getHaditsList({bool strict = false}) async { Future<List<Map<String, dynamic>>> getHaditsList(
{bool strict = false}) async {
if (_haditsCache != null) return _haditsCache!; if (_haditsCache != null) return _haditsCache!;
final raw = strict final raw = strict
? await _getDataOrThrow('/v1/hadits') ? await _getDataOrThrow('/v1/hadits')

View 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));
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
} }

View 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));
}
}

View 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},
);
}
}

View File

@@ -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,
), ),
); );
} }

View File

@@ -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 =

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/arabic_text.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart'; import '../../../data/services/muslim_api_service.dart';
class DoaScreen extends StatefulWidget { class DoaScreen extends StatefulWidget {
@@ -70,6 +74,62 @@ class _DoaScreenState extends State<DoaScreen> {
}); });
} }
void _showArabicFontSettings() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!settings.isInBox) {
settingsBox.put('default', settings);
}
double arabicFontSize = settings.arabicFontSize;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text('Ukuran Font Arab'),
Slider(
value: arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (value) {
setModalState(() => arabicFontSize = value);
settings.arabicFontSize = value;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
},
),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -78,15 +138,24 @@ class _DoaScreenState extends State<DoaScreen> {
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !widget.isSimpleModeTab, automaticallyImplyLeading: !widget.isSimpleModeTab,
title: const Text('Kumpulan Doa'), title: const Text('Kumpulan Doa'),
actionsPadding: const EdgeInsets.only(right: 8),
actions: [ actions: [
IconButton( IconButton(
onPressed: _loadDoa, onPressed: _loadDoa,
icon: const Icon(LucideIcons.refreshCw), icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang', tooltip: 'Muat ulang',
), ),
IconButton(
onPressed: _showArabicFontSettings,
icon: const Icon(LucideIcons.settings2),
tooltip: 'Pengaturan tampilan',
),
], ],
), ),
body: Column( body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
@@ -142,12 +211,14 @@ class _DoaScreenState extends State<DoaScreen> {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: isDark color: isDark
? AppColors.primary.withValues(alpha: 0.1) ? AppColors.primary
.withValues(alpha: 0.1)
: AppColors.cream, : AppColors.cream,
), ),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
item['judul']?.toString() ?? '-', item['judul']?.toString() ?? '-',
@@ -160,17 +231,14 @@ class _DoaScreenState extends State<DoaScreen> {
const SizedBox(height: 10), const SizedBox(height: 10),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: ArabicText(
item['arab']?.toString() ?? '', item['arab']?.toString() ?? '',
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: const TextStyle( baseFontSize: 24,
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.8, height: 1.8,
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item['indo']?.toString() ?? '', item['indo']?.toString() ?? '',
@@ -181,7 +249,9 @@ class _DoaScreenState extends State<DoaScreen> {
: AppColors.textSecondaryLight, : AppColors.textSecondaryLight,
), ),
), ),
if ((item['source']?.toString().isNotEmpty ?? if ((item['source']
?.toString()
.isNotEmpty ??
false)) ...[ false)) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
@@ -201,6 +271,7 @@ class _DoaScreenState extends State<DoaScreen> {
), ),
], ],
), ),
),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/arabic_text.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart'; import '../../../data/services/muslim_api_service.dart';
class HaditsScreen extends StatefulWidget { class HaditsScreen extends StatefulWidget {
@@ -75,6 +79,62 @@ class _HaditsScreenState extends State<HaditsScreen> {
}); });
} }
void _showArabicFontSettings() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!settings.isInBox) {
settingsBox.put('default', settings);
}
double arabicFontSize = settings.arabicFontSize;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text('Ukuran Font Arab'),
Slider(
value: arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (value) {
setModalState(() => arabicFontSize = value);
settings.arabicFontSize = value;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
},
),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -83,15 +143,24 @@ class _HaditsScreenState extends State<HaditsScreen> {
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !widget.isSimpleModeTab, automaticallyImplyLeading: !widget.isSimpleModeTab,
title: const Text("Hadits Arba'in"), title: const Text("Hadits Arba'in"),
actionsPadding: const EdgeInsets.only(right: 8),
actions: [ actions: [
IconButton( IconButton(
onPressed: _loadHadits, onPressed: _loadHadits,
icon: const Icon(LucideIcons.refreshCw), icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang', tooltip: 'Muat ulang',
), ),
IconButton(
onPressed: _showArabicFontSettings,
icon: const Icon(LucideIcons.settings2),
tooltip: 'Pengaturan tampilan',
),
], ],
), ),
body: Column( body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
@@ -147,12 +216,14 @@ class _HaditsScreenState extends State<HaditsScreen> {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: isDark color: isDark
? AppColors.primary.withValues(alpha: 0.1) ? AppColors.primary
.withValues(alpha: 0.1)
: AppColors.cream, : AppColors.cream,
), ),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
@@ -163,7 +234,8 @@ class _HaditsScreenState extends State<HaditsScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary color: AppColors.primary
.withValues(alpha: 0.12), .withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius:
BorderRadius.circular(10),
), ),
child: Text( child: Text(
'${item['no'] ?? '-'}', '${item['no'] ?? '-'}',
@@ -189,17 +261,14 @@ class _HaditsScreenState extends State<HaditsScreen> {
const SizedBox(height: 10), const SizedBox(height: 10),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: ArabicText(
item['arab']?.toString() ?? '', item['arab']?.toString() ?? '',
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: const TextStyle( baseFontSize: 24,
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.8, height: 1.8,
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item['indo']?.toString() ?? '', item['indo']?.toString() ?? '',
@@ -218,6 +287,7 @@ class _HaditsScreenState extends State<HaditsScreen> {
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -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,

View File

@@ -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,
), ),
), ),

View File

@@ -0,0 +1,879 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/services/notification_analytics_service.dart';
import '../../../data/services/notification_inbox_service.dart';
import '../../../data/services/notification_service.dart';
class NotificationCenterScreen extends StatefulWidget {
const NotificationCenterScreen({super.key});
@override
State<NotificationCenterScreen> createState() =>
_NotificationCenterScreenState();
}
class _NotificationCenterScreenState extends State<NotificationCenterScreen>
with TickerProviderStateMixin {
late final TabController _tabController;
late Future<List<NotificationPendingAlert>> _alarmsFuture;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
unawaited(NotificationInboxService.instance.removeByType('prayer'));
_alarmsFuture = NotificationService.instance.pendingAlerts();
NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: const <String, dynamic>{'screen': 'notification_center'},
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _refreshAlarms() async {
setState(() {
_alarmsFuture = NotificationService.instance.pendingAlerts();
});
await _alarmsFuture;
}
Future<void> _markAllRead() async {
await NotificationInboxService.instance.markAllRead();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Semua pesan sudah ditandai terbaca.')),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inboxListenable = NotificationInboxService.instance.listenable();
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.pop(),
icon: const AppIcon(glyph: AppIcons.backArrow),
),
title: const Text('Pemberitahuan'),
centerTitle: false,
actions: [
ListenableBuilder(
listenable: _tabController.animation!,
builder: (context, _) {
final tabIndex = _tabController.index;
if (tabIndex == 0) {
return IconButton(
onPressed: _refreshAlarms,
icon: Icon(
Icons.refresh_rounded,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
);
}
return ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread =
NotificationInboxService.instance.unreadCount();
if (unread <= 0) return const SizedBox.shrink();
return TextButton(
onPressed: _markAllRead,
child: const Text('Tandai semua'),
);
},
);
},
),
const SizedBox(width: 6),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: AppColors.primary,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
tabs: [
FutureBuilder<List<NotificationPendingAlert>>(
future: _alarmsFuture,
builder: (context, snapshot) {
final count = snapshot.data?.length ?? 0;
return Tab(text: count > 0 ? 'Alarm ($count)' : 'Alarm');
},
),
ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread = NotificationInboxService.instance.unreadCount();
return Tab(text: unread > 0 ? 'Pesan ($unread)' : 'Pesan');
},
),
],
),
),
body: SafeArea(
top: false,
child: TabBarView(
controller: _tabController,
children: [
_AlarmTab(future: _alarmsFuture, onRefresh: _refreshAlarms),
_InboxTab(),
],
),
),
);
}
}
class _AlarmTab extends StatefulWidget {
const _AlarmTab({
required this.future,
required this.onRefresh,
});
final Future<List<NotificationPendingAlert>> future;
final Future<void> Function() onRefresh;
@override
State<_AlarmTab> createState() => _AlarmTabState();
}
class _AlarmTabState extends State<_AlarmTab> {
String _alarmFilter = 'upcoming';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return FutureBuilder<List<NotificationPendingAlert>>(
future: widget.future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final alarms = snapshot.data ?? const <NotificationPendingAlert>[];
final now = DateTime.now();
final upcoming = alarms
.where((alarm) =>
alarm.scheduledAt == null || !alarm.scheduledAt!.isBefore(now))
.toList();
final passed = alarms
.where((alarm) =>
alarm.scheduledAt != null && alarm.scheduledAt!.isBefore(now))
.toList();
final visible = _alarmFilter == 'past' ? passed : upcoming;
if (alarms.isEmpty) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: 0,
passedCount: 0,
),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada alarm aktif',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Alarm adzan dan iqamah akan muncul di sini saat sudah dijadwalkan.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: upcoming.length,
passedCount: passed.length,
),
const SizedBox(height: 12),
if (visible.isEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream,
),
),
child: Text(
_alarmFilter == 'upcoming'
? 'Tidak ada alarm akan datang.'
: 'Belum ada alarm sudah lewat.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
for (final alarm in visible) ...[
_buildAlarmItem(context, isDark: isDark, alarm: alarm),
const SizedBox(height: 10),
],
],
),
);
},
);
}
Widget _buildAlarmItem(
BuildContext context, {
required bool isDark,
required NotificationPendingAlert alarm,
}) {
final chipColor = _chipColor(alarm.type);
final when = _formatAlarmTime(alarm.scheduledAt);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.16)
: AppColors.cream,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: chipColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: AppIcons.notification,
size: 18,
color: chipColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alarm.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
if (alarm.body.isNotEmpty)
Text(
alarm.body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _alarmLabel(alarm.type),
color: chipColor,
),
const SizedBox(width: 8),
Text(
when,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
],
),
);
}
Widget _buildAlarmFilters({
required bool isDark,
required int upcomingCount,
required int passedCount,
}) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: upcomingCount > 0
? 'Akan Datang ($upcomingCount)'
: 'Akan Datang',
selected: _alarmFilter == 'upcoming',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'upcoming'),
),
_FilterChip(
label: passedCount > 0 ? 'Sudah Lewat ($passedCount)' : 'Sudah Lewat',
selected: _alarmFilter == 'past',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'past'),
),
],
);
}
Color _chipColor(String type) {
switch (type) {
case 'adhan':
return AppColors.primary;
case 'iqamah':
return const Color(0xFF7B61FF);
case 'checklist':
return const Color(0xFF2D98DA);
case 'system':
return const Color(0xFFE17055);
default:
return AppColors.sage;
}
}
String _alarmLabel(String type) {
switch (type) {
case 'adhan':
return 'Adzan';
case 'iqamah':
return 'Iqamah';
case 'checklist':
return 'Checklist';
case 'system':
return 'Sistem';
default:
return 'Alarm';
}
}
String _formatAlarmTime(DateTime? value) {
if (value == null) return 'Waktu tidak diketahui';
try {
return DateFormat('EEE, d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _InboxTab extends StatefulWidget {
@override
State<_InboxTab> createState() => _InboxTabState();
}
class _InboxTabState extends State<_InboxTab> {
String _filter = 'all';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inbox = NotificationInboxService.instance;
return ValueListenableBuilder(
valueListenable: inbox.listenable(),
builder: (context, _, __) {
final items = inbox.allItems(filter: _filter);
if (items.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada pesan',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Pesan sistem dan ringkasan ibadah akan muncul di sini.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 12),
...items.map((item) {
final accent = _inboxAccent(item.type);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
background: _swipeBackground(
isDark: isDark,
icon: item.isRead ? Icons.mark_email_unread : Icons.done,
label: item.isRead ? 'Belum dibaca' : 'Tandai dibaca',
alignment: Alignment.centerLeft,
color: AppColors.primary,
),
secondaryBackground: _swipeBackground(
isDark: isDark,
icon: Icons.delete_outline,
label: 'Hapus',
alignment: Alignment.centerRight,
color: AppColors.errorLight,
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
if (item.isRead) {
await inbox.markUnread(item.id);
} else {
await inbox.markRead(item.id);
}
return false;
}
await inbox.remove(item.id);
return true;
},
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () async {
if (!item.isRead) {
await inbox.markRead(item.id);
}
await NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: <String, dynamic>{
'event_type': item.type,
'deeplink': item.deeplink ?? '',
},
);
if (!context.mounted) return;
final deeplink = item.deeplink;
if (deeplink != null && deeplink.isNotEmpty) {
if (deeplink.startsWith('/')) {
context.go(deeplink);
} else {
context.push(deeplink);
}
}
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: !item.isRead
? accent.withValues(alpha: isDark ? 0.4 : 0.32)
: (isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: _inboxGlyph(item.type),
size: 18,
color: accent,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w700),
),
),
if (item.isPinned)
const Padding(
padding: EdgeInsets.only(right: 6),
child: Icon(
Icons.push_pin_rounded,
size: 15,
color: AppColors.navActiveGoldDeep,
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 6),
Text(
item.body,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _inboxLabel(item.type),
color: accent,
),
const SizedBox(width: 8),
Text(
_formatInboxTime(item.createdAt),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
IconButton(
onPressed: () => inbox.togglePinned(item.id),
icon: Icon(
item.isPinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 18,
color: item.isPinned
? AppColors.navActiveGoldDeep
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
),
),
);
}),
],
);
},
);
}
Widget _buildFilters(bool isDark) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: 'Semua',
selected: _filter == 'all',
isDark: isDark,
onTap: () => setState(() => _filter = 'all'),
),
_FilterChip(
label: 'Belum Dibaca',
selected: _filter == 'unread',
isDark: isDark,
onTap: () => setState(() => _filter = 'unread'),
),
_FilterChip(
label: 'Sistem',
selected: _filter == 'system',
isDark: isDark,
onTap: () => setState(() => _filter = 'system'),
),
],
);
}
Widget _swipeBackground({
required bool isDark,
required IconData icon,
required String label,
required Alignment alignment,
required Color color,
}) {
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
],
),
);
}
AppIconGlyph _inboxGlyph(String type) {
switch (type) {
case 'system':
return AppIcons.settings;
case 'summary':
case 'streak_risk':
return AppIcons.laporan;
case 'prayer':
return AppIcons.notification;
case 'content':
return AppIcons.notification;
default:
return AppIcons.notification;
}
}
Color _inboxAccent(String type) {
switch (type) {
case 'system':
return const Color(0xFFE17055);
case 'summary':
return const Color(0xFF7B61FF);
case 'prayer':
return AppColors.primary;
case 'content':
return const Color(0xFF00CEC9);
default:
return AppColors.sage;
}
}
String _inboxLabel(String type) {
switch (type) {
case 'system':
return 'Sistem';
case 'summary':
return 'Ringkasan';
case 'streak_risk':
return 'Pengingat';
case 'prayer':
return 'Sholat';
case 'content':
return 'Konten';
default:
return 'Pesan';
}
}
String _formatInboxTime(DateTime value) {
final now = DateTime.now();
final isToday = now.year == value.year &&
now.month == value.month &&
now.day == value.day;
try {
if (isToday) {
return DateFormat('HH:mm', 'id_ID').format(value);
}
return DateFormat('d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
if (isToday) {
return DateFormat('HH:mm').format(value);
}
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.selected,
required this.isDark,
required this.onTap,
});
final String label;
final bool selected;
final bool isDark;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected
? AppColors.primary.withValues(alpha: isDark ? 0.22 : 0.16)
: (isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLightElevated),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.primary
: (isDark
? AppColors.primary.withValues(alpha: 0.2)
: AppColors.cream),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: selected
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
),
);
}
}
class _TypeBadge extends StatelessWidget {
const _TypeBadge({
required this.label,
required this.color,
});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,18 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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/arabic_text.dart';
import '../../../data/local/hive_boxes.dart'; import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/quran_bookmark.dart'; import '../../../data/local/models/quran_bookmark.dart';
import '../../../data/local/models/app_settings.dart'; import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart';
class QuranBookmarksScreen extends StatefulWidget { class QuranBookmarksScreen extends StatefulWidget {
const QuranBookmarksScreen({super.key}); final bool isSimpleModeTab;
const QuranBookmarksScreen({
super.key,
this.isSimpleModeTab = false,
});
@override @override
State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState(); State<QuranBookmarksScreen> createState() => _QuranBookmarksScreenState();
@@ -18,6 +24,8 @@ class QuranBookmarksScreen extends StatefulWidget {
class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> { class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
bool _showLatin = true; bool _showLatin = true;
bool _showTerjemahan = true; bool _showTerjemahan = true;
final Map<int, Future<Map<String, dynamic>?>> _surahFutureCache = {};
final Map<dynamic, Future<_ResolvedBookmarkContent?>> _bookmarkFutureCache = {};
String _readingRoute(int surahId, int verseId) { String _readingRoute(int surahId, int verseId) {
final isSimple = final isSimple =
@@ -39,13 +47,16 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
void _showDisplaySettings() { void _showDisplaySettings() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) { builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -90,6 +101,59 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
); );
} }
Future<Map<String, dynamic>?> _getSurah(int surahId) {
return _surahFutureCache.putIfAbsent(
surahId,
() => MuslimApiService.instance.getSurah(surahId),
);
}
Future<_ResolvedBookmarkContent?> _loadResolvedBookmarkContent(
QuranBookmark bookmark,
) async {
final surah = await _getSurah(bookmark.surahId);
final verses = List<Map<String, dynamic>>.from(surah?['ayat'] ?? []);
final verseIndex = bookmark.verseId - 1;
if (verseIndex < 0 || verseIndex >= verses.length) return null;
final verse = verses[verseIndex];
final resolved = _ResolvedBookmarkContent(
verseText: verse['teksArab']?.toString().trim().isNotEmpty == true
? verse['teksArab'].toString().trim()
: bookmark.verseText,
verseLatin: verse['teksLatin']?.toString().trim().isNotEmpty == true
? verse['teksLatin'].toString().trim()
: bookmark.verseLatin,
verseTranslation:
verse['teksIndonesia']?.toString().trim().isNotEmpty == true
? verse['teksIndonesia'].toString().trim()
: bookmark.verseTranslation,
);
final needsUpdate = bookmark.verseText != resolved.verseText ||
bookmark.verseLatin != resolved.verseLatin ||
bookmark.verseTranslation != resolved.verseTranslation;
if (needsUpdate) {
bookmark.verseText = resolved.verseText;
bookmark.verseLatin = resolved.verseLatin;
bookmark.verseTranslation = resolved.verseTranslation;
await bookmark.save();
}
return resolved;
}
Future<_ResolvedBookmarkContent?> _getResolvedBookmarkContent(
QuranBookmark bookmark,
) {
final bookmarkKey = bookmark.key ?? '${bookmark.surahId}_${bookmark.verseId}';
return _bookmarkFutureCache.putIfAbsent(
bookmarkKey,
() => _loadResolvedBookmarkContent(bookmark),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -105,8 +169,12 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
), ),
], ],
), ),
body: ValueListenableBuilder( body: SafeArea(
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(), top: false,
bottom: !widget.isSimpleModeTab,
child: ValueListenableBuilder(
valueListenable:
Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
builder: (context, Box<QuranBookmark> box, _) { builder: (context, Box<QuranBookmark> box, _) {
if (box.isEmpty) { if (box.isEmpty) {
return Center( return Center(
@@ -132,7 +200,9 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
'Tandai ayat saat membaca Al-Quran', 'Tandai ayat saat membaca Al-Quran',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -161,10 +231,10 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildBookmarkCard(context, lastRead.first, isDark, box, isLastRead: true), _buildBookmarkCard(context, lastRead.first, isDark, box,
isLastRead: true),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
if (favorites.isNotEmpty) ...[ if (favorites.isNotEmpty) ...[
const Text( const Text(
'AYAT FAVORIT', 'AYAT FAVORIT',
@@ -178,21 +248,27 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
...favorites.map((fav) => Padding( ...favorites.map((fav) => Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: _buildBookmarkCard(context, fav, isDark, box, isLastRead: false), child: _buildBookmarkCard(context, fav, isDark, box,
isLastRead: false),
)), )),
], ],
], ],
); );
}, },
), ),
),
); );
} }
Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark, bool isDark, Box<QuranBookmark> box, {required bool isLastRead}) { Widget _buildBookmarkCard(BuildContext context, QuranBookmark bookmark,
bool isDark, Box<QuranBookmark> box,
{required bool isLastRead}) {
final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt); final dateStr = DateFormat('dd MMM yyyy, HH:mm').format(bookmark.savedAt);
final resolvedFuture = _getResolvedBookmarkContent(bookmark);
return InkWell( return InkWell(
onTap: () => context.push(_readingRoute(bookmark.surahId, bookmark.verseId)), onTap: () =>
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -202,16 +278,20 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
border: Border.all( border: Border.all(
color: isLastRead color: isLastRead
? AppColors.primary.withValues(alpha: 0.3) ? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.1) : AppColors.cream), : (isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream),
width: isLastRead ? 1.5 : 1.0, width: isLastRead ? 1.5 : 1.0,
), ),
boxShadow: isLastRead ? [ boxShadow: isLastRead
? [
BoxShadow( BoxShadow(
color: AppColors.primary.withValues(alpha: 0.05), color: AppColors.primary.withValues(alpha: 0.05),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
) )
] : null, ]
: null,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -220,7 +300,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1), color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -229,7 +310,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isLastRead) ...[ if (isLastRead) ...[
const Icon(LucideIcons.pin, size: 12, color: AppColors.primary), const Icon(LucideIcons.pin,
size: 12, color: AppColors.primary),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
Text( Text(
@@ -244,7 +326,8 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
), ),
), ),
IconButton( IconButton(
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 20), icon: const Icon(LucideIcons.trash2,
color: Colors.red, size: 20),
onPressed: () => box.delete(bookmark.key), onPressed: () => box.delete(bookmark.key),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
@@ -252,24 +335,33 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FutureBuilder<_ResolvedBookmarkContent?>(
future: resolvedFuture,
builder: (context, snapshot) {
final content = snapshot.data ??
_ResolvedBookmarkContent(
verseText: bookmark.verseText,
verseLatin: bookmark.verseLatin,
verseTranslation: bookmark.verseTranslation,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: ArabicText(
bookmark.verseText, content.verseText,
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: const TextStyle( baseFontSize: 22,
fontFamily: 'Amiri',
fontSize: 22,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.8, height: 1.8,
), ),
), ),
), if (_showLatin && content.verseLatin != null) ...[
if (_showLatin && bookmark.verseLatin != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
bookmark.verseLatin!, content.verseLatin!,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
@@ -277,51 +369,59 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
), ),
), ),
], ],
if (_showTerjemahan &&
if (_showTerjemahan && bookmark.verseTranslation != null) ...[ content.verseTranslation != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
bookmark.verseTranslation!, content.verseTranslation!,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
height: 1.6, height: 1.6,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
), ),
), ),
], ],
],
);
},
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isLastRead) ...[ if (isLastRead) ...[
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => onPressed: () => context
context.push(_readingRoute(bookmark.surahId, bookmark.verseId)), .push(_readingRoute(bookmark.surahId, bookmark.verseId)),
icon: const Icon(LucideIcons.bookOpen, size: 18), icon: const Icon(LucideIcons.bookOpen, size: 18),
label: const Text('Lanjutkan Membaca'), label: const Text('Lanjutkan Membaca'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
Row( Row(
children: [ children: [
Icon( Icon(
LucideIcons.clock, LucideIcons.clock,
size: 12, size: 12,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${isLastRead ? 'Ditandai' : 'Disimpan'}: $dateStr', '${isLastRead ? 'Ditandai' : 'Disimpan'}: $dateStr',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
), ),
), ),
], ],
@@ -332,3 +432,15 @@ class _QuranBookmarksScreenState extends State<QuranBookmarksScreen> {
); );
} }
} }
class _ResolvedBookmarkContent {
const _ResolvedBookmarkContent({
required this.verseText,
this.verseLatin,
this.verseTranslation,
});
final String verseText;
final String? verseLatin;
final String? verseTranslation;
}

View File

@@ -1,10 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/arabic_text.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart'; import '../../../data/services/muslim_api_service.dart';
class QuranEnrichmentScreen extends StatefulWidget { class QuranEnrichmentScreen extends StatefulWidget {
const QuranEnrichmentScreen({super.key}); final bool isSimpleModeTab;
const QuranEnrichmentScreen({
super.key,
this.isSimpleModeTab = false,
});
@override @override
State<QuranEnrichmentScreen> createState() => _QuranEnrichmentScreenState(); State<QuranEnrichmentScreen> createState() => _QuranEnrichmentScreenState();
@@ -15,12 +23,12 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
late TabController _tabController; late TabController _tabController;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _pageController = TextEditingController(text: '1'); final TextEditingController _pageController =
TextEditingController(text: '1');
List<Map<String, dynamic>> _surahs = []; List<Map<String, dynamic>> _surahs = [];
List<Map<String, dynamic>> _searchResults = []; List<Map<String, dynamic>> _searchResults = [];
List<Map<String, dynamic>> _tafsirItems = []; List<Map<String, dynamic>> _tafsirItems = [];
List<Map<String, dynamic>> _asbabItems = [];
List<Map<String, dynamic>> _juzItems = []; List<Map<String, dynamic>> _juzItems = [];
List<Map<String, dynamic>> _pageItems = []; List<Map<String, dynamic>> _pageItems = [];
List<Map<String, dynamic>> _themeItems = []; List<Map<String, dynamic>> _themeItems = [];
@@ -31,7 +39,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
bool _loadingInit = true; bool _loadingInit = true;
bool _loadingSearch = false; bool _loadingSearch = false;
bool _loadingTafsir = false; bool _loadingTafsir = false;
bool _loadingAsbab = false;
bool _loadingPage = false; bool _loadingPage = false;
String? _error; String? _error;
@@ -42,7 +49,7 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 7, vsync: this); _tabController = TabController(length: 6, vsync: this);
_bootstrap(); _bootstrap();
} }
@@ -69,9 +76,8 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_surahs = surahs; _surahs = surahs;
_selectedSurahId = surahs.isNotEmpty _selectedSurahId =
? ((surahs.first['nomor'] as int?) ?? 1) surahs.isNotEmpty ? ((surahs.first['nomor'] as int?) ?? 1) : 1;
: 1;
_juzItems = juz; _juzItems = juz;
_themeItems = themes; _themeItems = themes;
_asmaItems = asma; _asmaItems = asma;
@@ -79,7 +85,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
}); });
await _loadTafsirForSelectedSurah(); await _loadTafsirForSelectedSurah();
await _loadAsbabForSelectedSurah();
await _loadPageAyah(); await _loadPageAyah();
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
@@ -117,16 +122,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
}); });
} }
Future<void> _loadAsbabForSelectedSurah() async {
setState(() => _loadingAsbab = true);
final result = await MuslimApiService.instance.getAsbabBySurah(_selectedSurahId);
if (!mounted) return;
setState(() {
_asbabItems = result;
_loadingAsbab = false;
});
}
Future<void> _loadPageAyah() async { Future<void> _loadPageAyah() async {
setState(() => _loadingPage = true); setState(() => _loadingPage = true);
final page = int.tryParse(_pageController.text.trim()) ?? _selectedPage; final page = int.tryParse(_pageController.text.trim()) ?? _selectedPage;
@@ -181,6 +176,62 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
return 'Surah $surahId'; return 'Surah $surahId';
} }
void _showArabicFontSettings() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!settings.isInBox) {
settingsBox.put('default', settings);
}
double arabicFontSize = settings.arabicFontSize;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text('Ukuran Font Arab'),
Slider(
value: arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (value) {
setModalState(() => arabicFontSize = value);
settings.arabicFontSize = value;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
},
),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -188,12 +239,18 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Quran Enrichment'), title: const Text('Quran Enrichment'),
actionsPadding: const EdgeInsets.only(right: 8),
actions: [ actions: [
IconButton( IconButton(
onPressed: _bootstrap, onPressed: _bootstrap,
icon: const Icon(LucideIcons.refreshCw), icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang', tooltip: 'Muat ulang',
), ),
IconButton(
onPressed: _showArabicFontSettings,
icon: const Icon(LucideIcons.settings2),
tooltip: 'Pengaturan tampilan',
),
], ],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
@@ -206,7 +263,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
tabs: const [ tabs: const [
Tab(text: 'Cari'), Tab(text: 'Cari'),
Tab(text: 'Tafsir'), Tab(text: 'Tafsir'),
Tab(text: 'Asbab'),
Tab(text: 'Juz'), Tab(text: 'Juz'),
Tab(text: 'Halaman'), Tab(text: 'Halaman'),
Tab(text: 'Tema'), Tab(text: 'Tema'),
@@ -214,7 +270,10 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
], ],
), ),
), ),
body: _loadingInit body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: _loadingInit
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
@@ -232,13 +291,13 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
children: [ children: [
_buildSearchTab(context, isDark), _buildSearchTab(context, isDark),
_buildTafsirTab(context, isDark), _buildTafsirTab(context, isDark),
_buildAsbabTab(context, isDark),
_buildJuzTab(context, isDark), _buildJuzTab(context, isDark),
_buildPageTab(context, isDark), _buildPageTab(context, isDark),
_buildThemeTab(context, isDark), _buildThemeTab(context, isDark),
_buildAsmaTab(context, isDark), _buildAsmaTab(context, isDark),
], ],
), ),
),
); );
} }
@@ -342,17 +401,14 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Text( child: ArabicText(
ayah['arab']?.toString() ?? '', ayah['arab']?.toString() ?? '',
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: const TextStyle( baseFontSize: 24,
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.8, height: 1.8,
), ),
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
ayah['text']?.toString() ?? '', ayah['text']?.toString() ?? '',
@@ -396,14 +452,11 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( ArabicText(
word['arab']?.toString() ?? '', word['arab']?.toString() ?? '',
style: const TextStyle( baseFontSize: 18,
fontFamily: 'Amiri',
fontSize: 18,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
word['word']?.toString() ?? '', word['word']?.toString() ?? '',
@@ -474,41 +527,6 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
); );
} }
Widget _buildAsbabTab(BuildContext context, bool isDark) {
return Column(
children: [
_buildSurahSelector(
onChanged: (value) {
setState(() => _selectedSurahId = value);
_loadAsbabForSelectedSurah();
},
),
Expanded(
child: _loadingAsbab
? const Center(child: CircularProgressIndicator())
: _asbabItems.isEmpty
? _emptyText(
isDark,
'Belum ada data asbabun nuzul untuk surah ini',
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: _asbabItems.length,
itemBuilder: (context, index) {
final item = _asbabItems[index];
final ayah = item['nomorAyat']?.toString() ?? '-';
return _buildCard(
isDark,
title: 'Ayat $ayah',
body: item['text']?.toString() ?? '',
);
},
),
),
],
);
}
Widget _buildJuzTab(BuildContext context, bool isDark) { Widget _buildJuzTab(BuildContext context, bool isDark) {
if (_juzItems.isEmpty) { if (_juzItems.isEmpty) {
return _emptyText(isDark, 'Data juz tidak tersedia'); return _emptyText(isDark, 'Data juz tidak tersedia');
@@ -575,11 +593,11 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
final surahId = (item['surah'] as num?)?.toInt() ?? 0; final surahId = (item['surah'] as num?)?.toInt() ?? 0;
final ayah = item['ayah']?.toString() ?? '-'; final ayah = item['ayah']?.toString() ?? '-';
return _buildCard( return _buildArabicCard(
isDark, isDark,
title: '${_surahNameById(surahId)} : $ayah', title: '${_surahNameById(surahId)} : $ayah',
body: arabic: item['arab']?.toString() ?? '',
'${item['arab']?.toString() ?? ''}\n\n${item['text']?.toString() ?? ''}', translation: item['text']?.toString() ?? '',
); );
}, },
), ),
@@ -652,14 +670,11 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( ArabicText(
item['arab']?.toString() ?? '', item['arab']?.toString() ?? '',
style: const TextStyle( baseFontSize: 22,
fontFamily: 'Amiri',
fontSize: 22,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
),
Text( Text(
item['latin']?.toString() ?? '', item['latin']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
@@ -727,7 +742,54 @@ class _QuranEnrichmentScreenState extends State<QuranEnrichmentScreen>
); );
} }
Widget _buildCard(bool isDark, {required String title, required String body}) { Widget _buildArabicCard(
bool isDark, {
required String title,
required String arabic,
required String translation,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: ArabicText(
arabic,
textAlign: TextAlign.right,
baseFontSize: 22,
fontWeight: FontWeight.w400,
height: 1.8,
),
),
const SizedBox(height: 8),
Text(translation, style: const TextStyle(height: 1.5)),
],
),
);
}
Widget _buildCard(bool isDark,
{required String title, required String body}) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),

View File

@@ -3,12 +3,14 @@ import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart'; import '../../../app/theme/app_colors.dart';
import '../../../core/services/app_audio_player.dart';
import '../../../data/services/muslim_api_service.dart'; import '../../../data/services/muslim_api_service.dart';
import '../../../data/services/unsplash_service.dart'; import '../../../data/services/unsplash_service.dart';
@@ -32,8 +34,9 @@ class QuranMurattalScreen extends ConsumerStatefulWidget {
_QuranMurattalScreenState(); _QuranMurattalScreenState();
} }
class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> { class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen>
final AudioPlayer _audioPlayer = AudioPlayer(); with SingleTickerProviderStateMixin {
final AudioPlayer _audioPlayer = AppAudioPlayer.instance;
Map<String, dynamic>? _surahData; Map<String, dynamic>? _surahData;
bool _isLoading = true; bool _isLoading = true;
@@ -56,11 +59,17 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
// Unsplash Background // Unsplash Background
Map<String, String>? _unsplashPhoto; Map<String, String>? _unsplashPhoto;
late final AnimationController _goldRingController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedQariId = widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi _selectedQariId =
widget.initialQariId ?? '05'; // Default to Misyari Rasyid Al-Afasi
_goldRingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 5000),
)..repeat();
_initDataAndPlayer(); _initDataAndPlayer();
_loadUnsplashPhoto(); _loadUnsplashPhoto();
} }
@@ -132,7 +141,22 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
final audioUrls = _surahData!['audioFull']; final audioUrls = _surahData!['audioFull'];
if (audioUrls != null && audioUrls[_selectedQariId] != null) { if (audioUrls != null && audioUrls[_selectedQariId] != null) {
try { try {
await _audioPlayer.setUrl(audioUrls[_selectedQariId]); final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final qariName = MuslimApiService.qariNames[_selectedQariId] ?? 'Qari';
final imageUrl = _unsplashPhoto?['imageUrl'];
await _audioPlayer.setAudioSource(
AudioSource.uri(
Uri.parse(audioUrls[_selectedQariId] as String),
tag: MediaItem(
id: 'murattal_${widget.surahId}_$_selectedQariId',
album: "Al-Qur'an Murattal",
title: 'Surah $surahName',
artist: qariName,
artUri: imageUrl != null ? Uri.tryParse(imageUrl) : null,
),
),
);
if (widget.autoPlay) { if (widget.autoPlay) {
_audioPlayer.play(); _audioPlayer.play();
} }
@@ -151,10 +175,22 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
_positionSub?.cancel(); _positionSub?.cancel();
_durationSub?.cancel(); _durationSub?.cancel();
_playerStateSub?.cancel(); _playerStateSub?.cancel();
_audioPlayer.dispose(); _goldRingController.dispose();
super.dispose(); super.dispose();
} }
void _syncGoldRingAnimation({required bool reducedMotion}) {
if (reducedMotion) {
if (_goldRingController.isAnimating) {
_goldRingController.stop(canceled: false);
}
return;
}
if (!_goldRingController.isAnimating) {
_goldRingController.repeat();
}
}
String _formatDuration(Duration d) { String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
@@ -190,9 +226,15 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
} }
} }
void _navigateToQuranReading() {
final base = widget.isSimpleModeTab ? '/quran' : '/tools/quran';
context.push('$base/${widget.surahId}');
}
void _showQariSelector() { void _showQariSelector() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
@@ -222,14 +264,15 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
...MuslimApiService.qariNames.entries.map((entry) { ...MuslimApiService.qariNames.entries.map((entry) {
final isSelected = entry.key == _selectedQariId; final isSelected = entry.key == _selectedQariId;
return ListTile( return ListTile(
leading: Icon( leading: AppIcon(
isSelected ? LucideIcons.checkCircle2 : LucideIcons.circle, glyph: isSelected ? AppIcons.checkCircle : AppIcons.circle,
color: isSelected ? AppColors.primary : Colors.grey, color: isSelected ? AppColors.primary : Colors.grey,
), ),
title: Text( title: Text(
entry.value, entry.value,
style: TextStyle( style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null, color: isSelected ? AppColors.primary : null,
), ),
), ),
@@ -255,6 +298,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
@@ -316,7 +360,9 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isCurrentSurah ? Colors.white : AppColors.primary, color: isCurrentSurah
? Colors.white
: AppColors.primary,
), ),
), ),
), ),
@@ -324,8 +370,11 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
title: Text( title: Text(
surah['namaLatin'] ?? 'Surah $surahNum', surah['namaLatin'] ?? 'Surah $surahNum',
style: TextStyle( style: TextStyle(
fontWeight: isCurrentSurah ? FontWeight.bold : FontWeight.normal, fontWeight: isCurrentSurah
color: isCurrentSurah ? AppColors.primary : null, ? FontWeight.bold
: FontWeight.normal,
color:
isCurrentSurah ? AppColors.primary : null,
), ),
), ),
subtitle: Text( subtitle: Text(
@@ -333,7 +382,11 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
trailing: isCurrentSurah trailing: isCurrentSurah
? Icon(LucideIcons.music, color: AppColors.primary, size: 20) ? const AppIcon(
glyph: AppIcons.musicNote,
color: AppColors.primary,
size: 20,
)
: null, : null,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
@@ -359,8 +412,15 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final media = MediaQuery.maybeOf(context);
final reducedMotion = (media?.disableAnimations ?? false) ||
(media?.accessibleNavigation ?? false);
_syncGoldRingAnimation(reducedMotion: reducedMotion);
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}'; final surahName = _surahData?['namaLatin'] ?? 'Surah ${widget.surahId}';
final systemBottomInset = media?.viewPadding.bottom ?? 0.0;
final playerBottomPadding = 32 + systemBottomInset;
final playerReservedBottom = 280 + systemBottomInset;
final hasPhoto = _unsplashPhoto != null; final hasPhoto = _unsplashPhoto != null;
@@ -368,8 +428,10 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
extendBodyBehindAppBar: hasPhoto, extendBodyBehindAppBar: hasPhoto,
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, icon: AppIcon(
color: hasPhoto ? Colors.white : null), glyph: AppIcons.backArrow,
color: hasPhoto ? Colors.white : null,
),
onPressed: () { onPressed: () {
if (widget.isSimpleModeTab) { if (widget.isSimpleModeTab) {
context.go('/'); context.go('/');
@@ -378,6 +440,16 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
} }
}, },
), ),
actions: [
IconButton(
icon: AppIcon(
glyph: AppIcons.quran,
color: hasPhoto ? Colors.white : null,
),
tooltip: 'Buka Surah',
onPressed: _navigateToQuranReading,
),
],
backgroundColor: hasPhoto ? Colors.transparent : null, backgroundColor: hasPhoto ? Colors.transparent : null,
elevation: hasPhoto ? 0 : null, elevation: hasPhoto ? 0 : null,
iconTheme: hasPhoto ? const IconThemeData(color: Colors.white) : null, iconTheme: hasPhoto ? const IconThemeData(color: Colors.white) : null,
@@ -462,7 +534,7 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 280, // leave room for the player bottom: playerReservedBottom, // leave room for player
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -500,7 +572,8 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(7, (i) { children: List.generate(7, (i) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(
horizontal: 2),
child: _EqualizerBar( child: _EqualizerBar(
isPlaying: _isPlaying, isPlaying: _isPlaying,
index: i, index: i,
@@ -517,7 +590,8 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Qari name // Qari name
Text( Text(
MuslimApiService.qariNames[_selectedQariId] ?? 'Memuat...', MuslimApiService.qariNames[_selectedQariId] ??
'Memuat...',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -545,26 +619,32 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
left: 0, left: 0,
right: 0, right: 0,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
child: BackdropFilter( child: BackdropFilter(
filter: _unsplashPhoto != null filter: ImageFilter.blur(
? ImageFilter.blur(sigmaX: 20, sigmaY: 20) sigmaX: _unsplashPhoto != null ? 20 : 14,
: ImageFilter.blur(sigmaX: 0, sigmaY: 0), sigmaY: _unsplashPhoto != null ? 20 : 14,
child: Container( ),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 48), child: Container(
decoration: BoxDecoration( padding: EdgeInsets.fromLTRB(
color: _unsplashPhoto != null 24, 16, 24, playerBottomPadding),
? Colors.white.withValues(alpha: 0.15) decoration: BoxDecoration(
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight), color: AppColors.primary.withValues(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), alpha: _unsplashPhoto != null
border: _unsplashPhoto != null ? 0.22
? Border( : (isDark ? 0.18 : 0.14),
top: BorderSide( ),
color: Colors.white.withValues(alpha: 0.2), borderRadius: const BorderRadius.vertical(
width: 0.5, top: Radius.circular(24)),
border: Border(
top: BorderSide(
color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.24)
: AppColors.primary.withValues(alpha: 0.32),
width: 0.7,
),
), ),
)
: null,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -573,15 +653,21 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
SliderTheme( SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
trackHeight: 3, trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6),
), ),
child: Slider( child: Slider(
value: _position.inMilliseconds.toDouble(), value: _position.inMilliseconds.toDouble(),
max: _duration.inMilliseconds > 0 ? _duration.inMilliseconds.toDouble() : 1.0, max: _duration.inMilliseconds > 0
? _duration.inMilliseconds.toDouble()
: 1.0,
onChanged: (v) { onChanged: (v) {
_audioPlayer.seek(Duration(milliseconds: v.round())); _audioPlayer
.seek(Duration(milliseconds: v.round()));
}, },
activeColor: _unsplashPhoto != null ? Colors.white : AppColors.primary, activeColor: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
inactiveColor: _unsplashPhoto != null inactiveColor: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.2) ? Colors.white.withValues(alpha: 0.2)
: AppColors.primary.withValues(alpha: 0.15), : AppColors.primary.withValues(alpha: 0.15),
@@ -589,9 +675,11 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
), ),
// Time labels // Time labels
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding:
const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_formatDuration(_position), _formatDuration(_position),
@@ -599,7 +687,9 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
fontSize: 12, fontSize: 12,
color: _unsplashPhoto != null color: _unsplashPhoto != null
? Colors.white70 ? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), : (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
), ),
), ),
Text( Text(
@@ -608,7 +698,9 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
fontSize: 12, fontSize: 12,
color: _unsplashPhoto != null color: _unsplashPhoto != null
? Colors.white70 ? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), : (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
), ),
), ),
], ],
@@ -621,27 +713,39 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
children: [ children: [
// Shuffle // Shuffle
IconButton( IconButton(
onPressed: () => setState(() => _isShuffleEnabled = !_isShuffleEnabled), onPressed: () => setState(() =>
icon: Icon( _isShuffleEnabled = !_isShuffleEnabled),
LucideIcons.shuffle, icon: AppIcon(
glyph: AppIcons.shuffle,
size: 24, size: 24,
color: _isShuffleEnabled color: _isShuffleEnabled
? (_unsplashPhoto != null ? Colors.white : AppColors.primary) ? (_unsplashPhoto != null
? Colors.white
: AppColors.primary)
: (_unsplashPhoto != null : (_unsplashPhoto != null
? Colors.white54 ? Colors.white54
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)), : (isDark
? AppColors.textSecondaryDark
: AppColors
.textSecondaryLight)),
), ),
), ),
// Previous Surah // Previous Surah
IconButton( IconButton(
onPressed: (int.tryParse(widget.surahId) ?? 1) > 1 onPressed:
(int.tryParse(widget.surahId) ?? 1) > 1
? () => _navigateToSurah(-1) ? () => _navigateToSurah(-1)
: null, : null,
icon: Icon( icon: AppIcon(
LucideIcons.skipBack, glyph: AppIcons.previousTrack,
size: 36, size: 36,
color: (int.tryParse(widget.surahId) ?? 1) > 1 color: (int.tryParse(widget.surahId) ?? 1) >
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)) 1
? (_unsplashPhoto != null
? Colors.white
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2), : Colors.grey.withValues(alpha: 0.2),
), ),
), ),
@@ -654,68 +758,96 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
_audioPlayer.play(); _audioPlayer.play();
} }
}, },
child: Container( child: SizedBox(
width: 64, width: 68,
height: 64, height: 68,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _goldRingController,
builder: (_, __) {
final ringProgress = reducedMotion
? 0.18
: _goldRingController.value;
return CustomPaint(
painter: _MurattalGoldRingPainter(
progress: ringProgress,
reducedMotion: reducedMotion,
isDark: isDark,
),
child: Padding(
padding: const EdgeInsets.all(3),
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _unsplashPhoto != null color: _unsplashPhoto != null
? Colors.white ? AppColors.brandTeal900
.withValues(
alpha: 0.88)
: AppColors.primary, : AppColors.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_unsplashPhoto != null
? Colors.white
: AppColors.primary)
.withValues(alpha: 0.3),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
), ),
child: _isBuffering child: _isBuffering
? Padding( ? Padding(
padding: const EdgeInsets.all(18.0), padding:
child: CircularProgressIndicator( const EdgeInsets
color: _unsplashPhoto != null .all(18.0),
? Colors.black87 child:
: Colors.white, CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3, strokeWidth: 3,
), ),
) )
: Icon( : Padding(
_isPlaying padding:
? LucideIcons.pause const EdgeInsets
: LucideIcons.play, .all(18),
size: 36, child: AppIcon(
color: _unsplashPhoto != null glyph: _isPlaying
? Colors.black87 ? AppIcons.pause
: AppColors.onPrimary, : AppIcons
.murattal,
size: 18,
color: AppColors
.onPrimary,
),
),
),
),
);
},
),
), ),
), ),
), ),
// Next Surah // Next Surah
IconButton( IconButton(
onPressed: (int.tryParse(widget.surahId) ?? 1) < 114 onPressed:
(int.tryParse(widget.surahId) ?? 1) < 114
? () => _navigateToSurah(1) ? () => _navigateToSurah(1)
: null, : null,
icon: Icon( icon: AppIcon(
LucideIcons.skipForward, glyph: AppIcons.nextTrack,
size: 36, size: 36,
color: (int.tryParse(widget.surahId) ?? 1) < 114 color: (int.tryParse(widget.surahId) ?? 1) <
? (_unsplashPhoto != null ? Colors.white : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight)) 114
? (_unsplashPhoto != null
? Colors.white
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight))
: Colors.grey.withValues(alpha: 0.2), : Colors.grey.withValues(alpha: 0.2),
), ),
), ),
// Playlist // Playlist
IconButton( IconButton(
onPressed: _showSurahPlaylist, onPressed: _showSurahPlaylist,
icon: Icon( icon: AppIcon(
LucideIcons.listMusic, glyph: AppIcons.playlist,
size: 28, size: 28,
color: _unsplashPhoto != null color: _unsplashPhoto != null
? Colors.white70 ? Colors.white70
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), : (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
), ),
), ),
], ],
@@ -730,27 +862,41 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _unsplashPhoto != null color: _unsplashPhoto != null
? Colors.white.withValues(alpha: 0.15) ? Colors.white.withValues(alpha: 0.15)
: AppColors.primary.withValues(alpha: 0.1), : AppColors.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.user, size: 16, AppIcon(
color: _unsplashPhoto != null ? Colors.white : AppColors.primary), glyph: AppIcons.user,
size: 16,
color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
MuslimApiService.qariNames[_selectedQariId] ?? 'Ganti Qari', MuslimApiService
.qariNames[_selectedQariId] ??
'Ganti Qari',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary, color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon(LucideIcons.chevronDown, AppIcon(
glyph: AppIcons.arrowDown,
size: 16, size: 16,
color: _unsplashPhoto != null ? Colors.white : AppColors.primary), color: _unsplashPhoto != null
? Colors.white
: AppColors.primary,
),
], ],
), ),
), ),
@@ -765,14 +911,15 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
// === ATTRIBUTION === // === ATTRIBUTION ===
if (_unsplashPhoto != null) if (_unsplashPhoto != null)
Positioned( Positioned(
bottom: 280, bottom: playerReservedBottom,
left: 0, left: 0,
right: 0, right: 0,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
final url = _unsplashPhoto!['photographerUrl']; final url = _unsplashPhoto!['photographerUrl'];
if (url != null && url.isNotEmpty) { if (url != null && url.isNotEmpty) {
launchUrl(Uri.parse('$url?utm_source=jamshalat_diary&utm_medium=referral')); launchUrl(Uri.parse(
'$url?utm_source=jamshalat_diary&utm_medium=referral'));
} }
}, },
child: Text( child: Text(
@@ -792,6 +939,180 @@ class _QuranMurattalScreenState extends ConsumerState<QuranMurattalScreen> {
} }
} }
class _MurattalGoldRingPainter extends CustomPainter {
const _MurattalGoldRingPainter({
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 center = Offset(size.width / 2, size.height / 2);
final outerRadius = (size.shortestSide / 2) - 0.8;
final ringRadius = (size.shortestSide / 2) - 2.0;
final innerRadius = (size.shortestSide / 2) - 3.8;
final rotation = reducedMotion ? pi * 0.63 : progress * pi * 2;
void drawEmboss({
required double radius,
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.drawCircle(center, radius, shadowPaint);
canvas.restore();
canvas.save();
canvas.translate(highlightOffset.dx, highlightOffset.dy);
canvas.drawCircle(center, radius, highlightPaint);
canvas.restore();
}
drawEmboss(
radius: outerRadius,
shadowOffset: const Offset(0.8, 1.1),
highlightOffset: const Offset(-0.65, -0.75),
shadowColor: isDark
? const Color(0xC4000000)
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
highlightColor: isDark
? Colors.white.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.88),
shadowBlur: isDark ? 2.6 : 1.9,
highlightBlur: isDark ? 1.7 : 1.2,
strokeWidth: 1.12,
);
drawEmboss(
radius: innerRadius,
shadowOffset: const Offset(-0.4, -0.4),
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.76),
shadowBlur: isDark ? 1.5 : 1.2,
highlightBlur: isDark ? 1.1 : 0.9,
strokeWidth: 0.96,
);
final ringRect = Rect.fromCircle(center: center, radius: ringRadius);
final metallic = SweepGradient(
startAngle: rotation,
endAngle: rotation + 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 = 2.8
..shader = metallic.createShader(ringRect)
..isAntiAlias = true;
canvas.drawCircle(center, ringRadius, metallicPaint);
final chromaStrength = isDark ? 0.92 : 0.74;
final chromaSweep = SweepGradient(
startAngle: (rotation * 1.3) + 0.42,
endAngle: (rotation * 1.3) + 0.42 + pi * 2,
colors: [
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
Colors.white.withValues(alpha: 0.92 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.66 * chromaStrength),
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
Colors.white.withValues(alpha: 0.84 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.6 * 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.55
..shader = chromaSweep.createShader(ringRect)
..blendMode = BlendMode.screen;
canvas.drawCircle(center, ringRadius, chromaPaint);
final ambient = Paint()
..style = PaintingStyle.stroke
..strokeWidth = isDark ? 1.0 : 0.86
..color = isDark
? AppColors.navActiveGold.withValues(alpha: 0.2)
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
..maskFilter = MaskFilter.blur(
BlurStyle.normal,
isDark ? 2.8 : 1.3,
);
canvas.drawCircle(center, ringRadius, ambient);
final innerEdge = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = isDark
? Colors.white.withValues(alpha: 0.12)
: const Color(0x330F172A);
canvas.drawCircle(center, innerRadius, innerEdge);
}
@override
bool shouldRepaint(covariant _MurattalGoldRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.reducedMotion != reducedMotion ||
oldDelegate.isDark != isDark;
}
}
/// Animated equalizer bar widget for the Murattal player. /// Animated equalizer bar widget for the Murattal player.
class _EqualizerBar extends StatefulWidget { class _EqualizerBar extends StatefulWidget {
final bool isPlaying; final bool isPlaying;
@@ -875,7 +1196,8 @@ class _EqualizerBarState extends State<_EqualizerBar>
width: 6, width: 6,
height: 50 * _animation.value, height: 50 * _animation.value,
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.color.withValues(alpha: 0.6 + (_animation.value * 0.4)), color:
widget.color.withValues(alpha: 0.6 + (_animation.value * 0.4)),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
); );

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import '../../../app/theme/app_colors.dart'; import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/arabic_text.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/quran_bookmark.dart'; import '../../../data/local/models/quran_bookmark.dart';
@@ -47,13 +48,16 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
void _showDisplaySettings() { void _showDisplaySettings() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) { builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -134,14 +138,18 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
), ),
], ],
), ),
body: Column( body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: Column(
children: [ children: [
// Search bar // Search bar
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, color:
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isDark color: isDark
@@ -158,8 +166,8 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
? AppColors.textSecondaryDark ? AppColors.textSecondaryDark
: AppColors.textSecondaryLight), : AppColors.textSecondaryLight),
border: InputBorder.none, border: InputBorder.none,
contentPadding: contentPadding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 16, vertical: 14), horizontal: 16, vertical: 14),
), ),
), ),
), ),
@@ -182,10 +190,13 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
), ),
) )
: ValueListenableBuilder( : ValueListenableBuilder(
valueListenable: Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(), valueListenable:
Hive.box<QuranBookmark>(HiveBoxes.bookmarks)
.listenable(),
builder: (context, box, _) { builder: (context, box, _) {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding:
const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length, itemCount: filtered.length,
separatorBuilder: (_, __) => Divider( separatorBuilder: (_, __) => Divider(
height: 1, height: 1,
@@ -202,10 +213,12 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
final tempatTurun = surah['tempatTurun'] ?? ''; final tempatTurun = surah['tempatTurun'] ?? '';
final arti = surah['arti'] ?? ''; final arti = surah['arti'] ?? '';
final hasLastRead = box.values.any((b) => b.isLastRead && b.surahId == number); final hasLastRead = box.values.any(
(b) => b.isLastRead && b.surahId == number);
return ListTile( return ListTile(
onTap: () => context.push(widget.isSimpleModeTab onTap: () => context.push(
widget.isSimpleModeTab
? '/quran/$number' ? '/quran/$number'
: '/tools/quran/$number'), : '/tools/quran/$number'),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -240,7 +253,8 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
), ),
if (hasLastRead) ...[ if (hasLastRead) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon(LucideIcons.pin, size: 14, color: AppColors.primary), const Icon(LucideIcons.pin,
size: 14, color: AppColors.primary),
], ],
], ],
), ),
@@ -253,14 +267,11 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
: AppColors.textSecondaryLight, : AppColors.textSecondaryLight,
), ),
), ),
trailing: Text( trailing: ArabicText(
nameArabic, nameArabic,
style: const TextStyle( baseFontSize: 18,
fontFamily: 'Amiri',
fontSize: 18,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
),
); );
}, },
); );
@@ -269,6 +280,7 @@ class _QuranScreenState extends ConsumerState<QuranScreen> {
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -9,7 +9,7 @@ import '../../../core/widgets/ios_toggle.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/myquran_sholat_service.dart'; import '../../../data/services/myquran_sholat_service.dart';
import '../../../data/services/myquran_sholat_service.dart'; import '../../../data/services/notification_service.dart';
import '../../dashboard/data/prayer_times_provider.dart'; import '../../dashboard/data/prayer_times_provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../data/local/models/daily_worship_log.dart'; import '../../../data/local/models/daily_worship_log.dart';
@@ -29,6 +29,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
super.initState(); super.initState();
final box = Hive.box<AppSettings>(HiveBoxes.settings); final box = Hive.box<AppSettings>(HiveBoxes.settings);
_settings = box.get('default') ?? AppSettings(); _settings = box.get('default') ?? AppSettings();
_enforceTilawahAutoSyncIfNeeded();
} }
void _saveSettings() { void _saveSettings() {
@@ -36,9 +37,29 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
setState(() {}); setState(() {});
} }
void _syncTodayTilawahAutoSync(bool value) {
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = logBox.get(todayKey);
if (log != null && log.tilawahLog != null) {
log.tilawahLog!.autoSync = value;
log.save();
}
}
void _enforceTilawahAutoSyncIfNeeded() {
if (_settings.simpleMode || _settings.tilawahAutoSync) return;
_settings.tilawahAutoSync = true;
if (_settings.isInBox) {
_settings.save();
} else {
Hive.box<AppSettings>(HiveBoxes.settings).put('default', _settings);
}
_syncTodayTilawahAutoSync(true);
}
bool get _isDarkMode => _settings.themeModeIndex != 1; bool get _isDarkMode => _settings.themeModeIndex != 1;
bool get _notificationsEnabled => bool get _prayerAlarmEnabled => _settings.adhanEnabled.values.any((v) => v);
_settings.adhanEnabled.values.any((v) => v);
String get _displayCityName { String get _displayCityName {
final stored = _settings.lastCityName ?? 'Jakarta'; final stored = _settings.lastCityName ?? 'Jakarta';
@@ -58,17 +79,176 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
void _toggleNotifications(bool value) { void _toggleNotifications(bool value) {
_settings.adhanEnabled.updateAll((key, _) => value); _settings.adhanEnabled.updateAll((key, _) => value);
_saveSettings(); _saveSettings();
if (!value) {
unawaited(NotificationService.instance.cancelAllPending());
}
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
}
void _toggleGlobalAlerts(bool value) {
_settings.alertsEnabled = value;
_saveSettings();
unawaited(NotificationService.instance.syncHabitNotifications(
settings: _settings,
));
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
}
void _toggleInbox(bool value) {
_settings.inboxEnabled = value;
_saveSettings();
}
Future<void> _showQuietHoursDialog(BuildContext context) async {
final startController =
TextEditingController(text: _settings.quietHoursStart);
final endController = TextEditingController(text: _settings.quietHoursEnd);
await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Jam Tenang'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: startController,
keyboardType: TextInputType.datetime,
decoration: const InputDecoration(
labelText: 'Mulai (HH:mm)',
),
),
const SizedBox(height: 10),
TextField(
controller: endController,
keyboardType: TextInputType.datetime,
decoration: const InputDecoration(
labelText: 'Selesai (HH:mm)',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
final start = startController.text.trim();
final end = endController.text.trim();
final valid = RegExp(r'^\d{1,2}:\d{2}$');
if (!valid.hasMatch(start) || !valid.hasMatch(end)) {
return;
}
_settings.quietHoursStart = start;
_settings.quietHoursEnd = end;
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
);
},
);
}
Future<void> _showPushCapDialog(BuildContext context) async {
final controller = TextEditingController(
text: _settings.maxNonPrayerPushPerDay.toString(),
);
await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Batas Push Non-Sholat'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Maksimal per hari',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
final value = int.tryParse(controller.text.trim());
if (value == null || value < 0 || value > 20) return;
_settings.maxNonPrayerPushPerDay = value;
_saveSettings();
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
);
},
);
}
Future<void> _showChecklistReminderTimeDialog(BuildContext context) async {
final controller = TextEditingController(
text: _settings.checklistReminderTime ?? '09:00',
);
await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Waktu Pengingat Checklist'),
content: TextField(
controller: controller,
keyboardType: TextInputType.datetime,
decoration: const InputDecoration(
labelText: 'HH:mm',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
FilledButton(
onPressed: () {
final raw = controller.text.trim();
if (!RegExp(r'^\d{1,2}:\d{2}$').hasMatch(raw)) return;
_settings.checklistReminderTime = raw;
_saveSettings();
unawaited(NotificationService.instance.syncHabitNotifications(
settings: _settings,
));
Navigator.pop(ctx);
},
child: const Text('Simpan'),
),
],
);
},
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final forceTilawahAutoSync = !_settings.simpleMode;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Pengaturan'), title: const Text('Pengaturan'),
), ),
body: ListView( body: SafeArea(
top: false,
bottom: true,
child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// ── Profile Card ── // ── Profile Card ──
@@ -154,12 +334,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: LucideIcons.layoutDashboard, icon: LucideIcons.layoutDashboard,
iconColor: const Color(0xFF0984E3), iconColor: const Color(0xFF0984E3),
title: 'Mode Aplikasi', title: 'Mode Aplikasi',
subtitle: _settings.simpleMode ? 'Simpel — Jadwal & Al-Quran' : 'Lengkap — Dengan Checklist & Poin', subtitle: _settings.simpleMode
? 'Simpel — Jadwal & Al-Quran'
: 'Lengkap — Dengan Checklist & Poin',
trailing: IosToggle( trailing: IosToggle(
value: !_settings.simpleMode, value: !_settings.simpleMode,
onChanged: (v) { onChanged: (v) {
_settings.simpleMode = !v; _settings.simpleMode = !v;
if (v) {
_settings.tilawahAutoSync = true;
}
_saveSettings(); _saveSettings();
_syncTodayTilawahAutoSync(_settings.tilawahAutoSync);
}, },
), ),
), ),
@@ -179,14 +365,109 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
isDark, isDark,
icon: LucideIcons.bell, icon: LucideIcons.bell,
iconColor: const Color(0xFFE17055), iconColor: const Color(0xFFE17055),
title: 'Notifikasi', title: 'Alarm Sholat',
trailing: IosToggle( trailing: IosToggle(
value: _notificationsEnabled, value: _prayerAlarmEnabled,
onChanged: _toggleNotifications, onChanged: _toggleNotifications,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_sectionLabel('PEMBERITAHUAN'),
const SizedBox(height: 12),
_settingRow(
isDark,
icon: LucideIcons.alertCircle,
iconColor: const Color(0xFF00B894),
title: 'Peringatan Non-Sholat',
subtitle: 'Streak, sistem, dan konten terbaru',
trailing: IosToggle(
value: _settings.alertsEnabled,
onChanged: _toggleGlobalAlerts,
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.inbox,
iconColor: const Color(0xFF6C5CE7),
title: 'Kotak Masuk Pesan',
subtitle: 'Simpan pesan untuk dibaca nanti',
trailing: IosToggle(
value: _settings.inboxEnabled,
onChanged: _toggleInbox,
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.activity,
iconColor: const Color(0xFF0984E3),
title: 'Peringatan Risiko Streak',
trailing: IosToggle(
value: _settings.streakRiskEnabled,
onChanged: (v) {
_settings.streakRiskEnabled = v;
_saveSettings();
},
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.barChart3,
iconColor: const Color(0xFF6C5CE7),
title: 'Ringkasan Mingguan',
trailing: IosToggle(
value: _settings.weeklySummaryEnabled,
onChanged: (v) {
_settings.weeklySummaryEnabled = v;
_saveSettings();
},
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.checkSquare,
iconColor: const Color(0xFF2D98DA),
title: 'Pengingat Checklist Harian',
subtitle: _settings.checklistReminderTime,
onTap: () => _showChecklistReminderTimeDialog(context),
trailing: IosToggle(
value: _settings.dailyChecklistReminderEnabled,
onChanged: (v) {
_settings.dailyChecklistReminderEnabled = v;
_saveSettings();
unawaited(NotificationService.instance.syncHabitNotifications(
settings: _settings,
));
},
),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.moonStar,
iconColor: const Color(0xFF636E72),
title: 'Jam Tenang',
subtitle:
'${_settings.quietHoursStart} - ${_settings.quietHoursEnd}',
trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () => _showQuietHoursDialog(context),
),
const SizedBox(height: 10),
_settingRow(
isDark,
icon: LucideIcons.gauge,
iconColor: const Color(0xFFE17055),
title: 'Batas Push Non-Sholat',
subtitle: '${_settings.maxNonPrayerPushPerDay} per hari',
trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () => _showPushCapDialog(context),
),
const SizedBox(height: 24),
// ── CHECKLIST IBADAH (always visible, even in Simple Mode per user request) ── // ── CHECKLIST IBADAH (always visible, even in Simple Mode per user request) ──
_sectionLabel('CHECKLIST IBADAH'), _sectionLabel('CHECKLIST IBADAH'),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -195,7 +476,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: LucideIcons.building, icon: LucideIcons.building,
iconColor: Colors.teal, iconColor: Colors.teal,
title: 'Tingkat Sholat Rawatib', title: 'Tingkat Sholat Rawatib',
subtitle: _settings.rawatibLevel == 0 ? 'Mati' : (_settings.rawatibLevel == 1 ? 'Muakkad Saja' : 'Lengkap (Semua)'), subtitle: _settings.rawatibLevel == 0
? 'Mati'
: (_settings.rawatibLevel == 1
? 'Muakkad Saja'
: 'Lengkap (Semua)'),
trailing: const Icon(LucideIcons.chevronRight, size: 20), trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () => _showRawatibDialog(context), onTap: () => _showRawatibDialog(context),
), ),
@@ -205,7 +490,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: LucideIcons.bookOpen, icon: LucideIcons.bookOpen,
iconColor: Colors.amber, iconColor: Colors.amber,
title: 'Target Tilawah', title: 'Target Tilawah',
subtitle: '${_settings.tilawahTargetValue} ${_settings.tilawahTargetUnit}', subtitle:
'${_settings.tilawahTargetValue} ${_settings.tilawahTargetUnit}',
trailing: const Icon(LucideIcons.chevronRight, size: 20), trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () => _showTilawahDialog(context), onTap: () => _showTilawahDialog(context),
), ),
@@ -215,23 +501,25 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: LucideIcons.refreshCw, icon: LucideIcons.refreshCw,
iconColor: Colors.blue, iconColor: Colors.blue,
title: 'Auto-Sync Tilawah', title: 'Auto-Sync Tilawah',
subtitle: 'Catat otomatis dari menu Al-Quran', subtitle: forceTilawahAutoSync
trailing: IosToggle( ? 'Mode Lengkap: selalu aktif'
value: _settings.tilawahAutoSync, : 'Catat otomatis dari menu Al-Quran',
trailing: IgnorePointer(
ignoring: forceTilawahAutoSync,
child: Opacity(
opacity: forceTilawahAutoSync ? 0.78 : 1,
child: IosToggle(
value:
forceTilawahAutoSync ? true : _settings.tilawahAutoSync,
onChanged: (v) { onChanged: (v) {
_settings.tilawahAutoSync = v; _settings.tilawahAutoSync = v;
_saveSettings(); _saveSettings();
_syncTodayTilawahAutoSync(v);
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = logBox.get(todayKey);
if (log != null && log.tilawahLog != null) {
log.tilawahLog!.autoSync = v;
log.save();
}
}, },
), ),
), ),
),
),
const SizedBox(height: 10), const SizedBox(height: 10),
_settingRow( _settingRow(
isDark, isDark,
@@ -422,6 +710,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
), ),
),
); );
} }
@@ -547,9 +836,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Container( Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color:
? AppColors.backgroundDark isDark ? AppColors.backgroundDark : AppColors.backgroundLight,
: AppColors.backgroundLight,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isDark color: isDark
@@ -570,9 +858,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
vertical: 10, vertical: 10,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color:
? AppColors.primary selected ? AppColors.primary : Colors.transparent,
: Colors.transparent,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Text( child: Text(
@@ -631,7 +918,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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,
@@ -662,12 +950,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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;
@@ -703,7 +993,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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,
@@ -803,7 +1094,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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('Waktu Iqamah (menit)'), title: const Text('Waktu Iqamah (menit)'),
content: SizedBox( content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85, width: MediaQuery.of(context).size.width * 0.85,
@@ -858,6 +1150,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
onPressed: () { onPressed: () {
_settings.iqamahOffset = offsets; _settings.iqamahOffset = offsets;
_saveSettings(); _saveSettings();
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
child: const Text('Simpan'), child: const Text('Simpan'),
@@ -883,32 +1177,51 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (bCtx) => Padding( isScrollControlled: true,
padding: const EdgeInsets.all(24.0), useSafeArea: true,
builder: (bCtx) {
final keyboardInset =
MediaQuery.of(bCtx).viewInsets.bottom;
return Padding(
padding:
EdgeInsets.fromLTRB(24, 24, 24, 24 + keyboardInset),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Informasi Sholat Rawatib', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const Text('Informasi Sholat Rawatib',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Muakkad (Sangat Ditekankan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)), const Text('Muakkad (Sangat Ditekankan)',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary)),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Total 10 atau 12 Rakaat:'), const Text('Total 10 atau 12 Rakaat:'),
const Padding( const Padding(
padding: EdgeInsets.only(left: 12, top: 4), padding: EdgeInsets.only(left: 12, top: 4),
child: Text('• 2 Rakaat sebelum Subuh\n• 2 atau 4 Rakaat sebelum Dzuhur\n• 2 Rakaat sesudah Dzuhur\n• 2 Rakaat sesudah Maghrib\n• 2 Rakaat sesudah Isya', style: TextStyle(height: 1.5)), child: Text(
'• 2 Rakaat sebelum Subuh\n• 2 atau 4 Rakaat sebelum Dzuhur\n• 2 Rakaat sesudah Dzuhur\n• 2 Rakaat sesudah Maghrib\n• 2 Rakaat sesudah Isya',
style: TextStyle(height: 1.5)),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Ghairu Muakkad (Tambahan)', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary)), const Text('Ghairu Muakkad (Tambahan)',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary)),
const SizedBox(height: 8), const SizedBox(height: 8),
const Padding( const Padding(
padding: EdgeInsets.only(left: 12), padding: EdgeInsets.only(left: 12),
child: Text('• Tambahan 2 Rakaat sesudah Dzuhur\n• 4 Rakaat sebelum Ashar\n• 2 Rakaat sebelum Maghrib\n• 2 Rakaat sebelum Isya', style: TextStyle(height: 1.5)), child: Text(
'• Tambahan 2 Rakaat sesudah Dzuhur\n• 4 Rakaat sebelum Ashar\n• 2 Rakaat sebelum Maghrib\n• 2 Rakaat sebelum Isya',
style: TextStyle(height: 1.5)),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
), );
},
); );
}, },
), ),
@@ -957,7 +1270,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
} }
void _showTilawahDialog(BuildContext context) { void _showTilawahDialog(BuildContext context) {
final qtyCtrl = TextEditingController(text: _settings.tilawahTargetValue.toString()); final qtyCtrl =
TextEditingController(text: _settings.tilawahTargetValue.toString());
String tempUnit = _settings.tilawahTargetUnit; String tempUnit = _settings.tilawahTargetUnit;
showDialog( showDialog(
@@ -972,7 +1286,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: TextField( child: TextField(
controller: qtyCtrl, controller: qtyCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration(border: OutlineInputBorder()), decoration:
const InputDecoration(border: OutlineInputBorder()),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -980,8 +1295,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
flex: 2, flex: 2,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: tempUnit, value: tempUnit,
decoration: const InputDecoration(border: OutlineInputBorder()), decoration:
items: ['Juz', 'Halaman', 'Ayat'].map((u) => DropdownMenuItem(value: u, child: Text(u))).toList(), const InputDecoration(border: OutlineInputBorder()),
items: ['Juz', 'Halaman', 'Ayat']
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) => setDialogState(() => tempUnit = v!), onChanged: (v) => setDialogState(() => tempUnit = v!),
), ),
), ),
@@ -1000,7 +1318,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_saveSettings(); _saveSettings();
// Update today's active checklist immediately // Update today's active checklist immediately
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now()); final todayKey =
DateFormat('yyyy-MM-dd').format(DateTime.now());
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs); final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = logBox.get(todayKey); final log = logBox.get(todayKey);
if (log != null && log.tilawahLog != null) { if (log != null && log.tilawahLog != null) {

View File

@@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:hive_flutter/hive_flutter.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart'; import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/ayat_today_card.dart';
import '../../../core/widgets/notification_bell_button.dart';
import '../../../core/widgets/tool_card.dart'; import '../../../core/widgets/tool_card.dart';
import '../../../data/services/muslim_api_service.dart'; import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
class ToolsScreen extends ConsumerWidget { class ToolsScreen extends ConsumerWidget {
const ToolsScreen({super.key}); const ToolsScreen({super.key});
@@ -12,19 +17,72 @@ class ToolsScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final isSimpleMode =
Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ??
false;
final cards = <Widget>[
if (!isSimpleMode)
ToolCard(
icon: AppIcons.quran,
title: "Al-Qur'an\nTerjemahan",
color: const Color(0xFF00B894),
isDark: isDark,
onTap: () => context.push('/tools/quran'),
),
ToolCard(
icon: AppIcons.murattal,
title: "Qur'an\nMurattal",
color: const Color(0xFF7B61FF),
isDark: isDark,
onTap: () => context.push('/tools/quran/1/murattal'),
),
ToolCard(
icon: AppIcons.qibla,
title: 'Arah\nKiblat',
color: const Color(0xFF0984E3),
isDark: isDark,
onTap: () => context.push('/tools/qibla'),
),
if (!isSimpleMode)
ToolCard(
icon: AppIcons.dzikir,
title: 'Dzikir\nHarian',
color: AppColors.primary,
isDark: isDark,
onTap: () => context.push('/tools/dzikir'),
),
ToolCard(
icon: AppIcons.doa,
title: 'Kumpulan\nDoa',
color: const Color(0xFFE17055),
isDark: isDark,
onTap: () => context.push('/tools/doa'),
),
ToolCard(
icon: AppIcons.hadits,
title: "Hadits\nArba'in",
color: const Color(0xFF6C5CE7),
isDark: isDark,
onTap: () => context.push('/tools/hadits'),
),
ToolCard(
icon: AppIcons.quranEnrichment,
title: "Pendalaman\nAl-Qur'an",
color: const Color(0xFF00CEC9),
isDark: isDark,
onTap: () => context.push('/tools/quran/enrichment'),
),
];
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Alat Islami'), title: const Text('Alat Islami'),
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 AppIcon(glyph: AppIcons.settings),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
@@ -34,7 +92,7 @@ class ToolsScreen extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( const Text(
'AKSES CEPAT', 'AKSES CEPAT',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -44,193 +102,37 @@ class ToolsScreen extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( _buildQuickActionsGrid(cards),
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.bookOpen,
title: 'Al-Quran\nTerjemahan',
color: const Color(0xFF00B894),
isDark: isDark,
onTap: () => context.push('/tools/quran'),
),
),
const SizedBox(width: 12),
Expanded(
child: ToolCard(
icon: LucideIcons.headphones,
title: 'Quran\nMurattal',
color: const Color(0xFF7B61FF),
isDark: isDark,
onTap: () => context.push('/tools/quran/1/murattal'),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.compass,
title: 'Arah\nKiblat',
color: const Color(0xFF0984E3),
isDark: isDark,
onTap: () => context.push('/tools/qibla'),
),
),
const SizedBox(width: 12),
Expanded(
child: ToolCard(
icon: LucideIcons.sparkles,
title: 'Dzikir\nHarian',
color: AppColors.primary,
isDark: isDark,
onTap: () => context.push('/tools/dzikir'),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.heart,
title: 'Kumpulan\nDoa',
color: const Color(0xFFE17055),
isDark: isDark,
onTap: () => context.push('/tools/doa'),
),
),
const SizedBox(width: 12),
Expanded(
child: ToolCard(
icon: LucideIcons.library,
title: "Hadits\nArba'in",
color: const Color(0xFF6C5CE7),
isDark: isDark,
onTap: () => context.push('/tools/hadits'),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.sparkles,
title: 'Quran\nEnrichment',
color: const Color(0xFF00CEC9),
isDark: isDark,
onTap: () => context.push('/tools/quran/enrichment'),
),
),
const Expanded(child: SizedBox()),
],
),
const SizedBox(height: 28), const SizedBox(height: 28),
FutureBuilder<Map<String, dynamic>?>( AyatTodayCard(
future: MuslimApiService.instance.getDailyAyat(), headerText: 'Ayat Hari Ini',
builder: (context, snapshot) { headerStyle: TextStyle(
if (snapshot.connectionState == ConnectionState.waiting) { fontSize: 13,
return Container( fontWeight: FontWeight.w600,
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark color: isDark
? AppColors.primary.withValues(alpha: 0.08) ? AppColors.textSecondaryDark
: const Color(0xFFF5F9F0), : AppColors.textSecondaryLight,
borderRadius: BorderRadius.circular(16), ),
),
],
),
), ),
child: const Center(child: CircularProgressIndicator()),
); );
} }
if (!snapshot.hasData || snapshot.data == null) { Widget _buildQuickActionsGrid(List<Widget> cards) {
return const SizedBox.shrink(); const spacing = 12.0;
} return LayoutBuilder(
builder: (context, constraints) {
final data = snapshot.data!; final cardWidth = (constraints.maxWidth - spacing) / 2;
return Container( return Wrap(
width: double.infinity, spacing: spacing,
padding: const EdgeInsets.all(20), runSpacing: spacing,
decoration: BoxDecoration(
color: isDark
? AppColors.primary.withValues(alpha: 0.08)
: const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( for (final card in cards) SizedBox(width: cardWidth, child: card),
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Ayat Hari Ini',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
IconButton(
icon: Icon(
LucideIcons.share2,
size: 18,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
onPressed: () {},
),
], ],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: Text(
data['teksArab'] ?? '',
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400,
height: 1.8,
),
textAlign: TextAlign.right,
),
),
const SizedBox(height: 12),
Text(
'"${data['teksIndonesia'] ?? ''}"',
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'QS. ${data['surahName']}: ${data['nomorAyat']}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
); );
}, },
),
],
),
),
); );
} }
} }

View File

@@ -1,9 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'app/app.dart'; import 'app/app.dart';
import 'data/local/hive_boxes.dart'; import 'data/local/hive_boxes.dart';
import 'data/local/models/app_settings.dart';
import 'data/services/notification_inbox_service.dart';
import 'data/services/notification_orchestrator_service.dart';
import 'data/services/remote_push_service.dart';
import 'data/services/notification_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -17,6 +27,31 @@ void main() async {
// Seed default settings and checklist items on first launch // Seed default settings and checklist items on first launch
await seedDefaults(); await seedDefaults();
// Ensure intl DateFormat locale data is ready before any localized formatting.
await initializeDateFormatting('id_ID');
// Initialize local notifications for adzan/iqamah scheduling
await NotificationService.instance.init();
await RemotePushService.instance.init();
// Run passive notification checks at startup (inbox cleanup/content sync).
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
// Cleanup legacy mirrored prayer inbox items.
await NotificationInboxService.instance.removeByType('prayer');
unawaited(NotificationService.instance.syncHabitNotifications(
settings: settings,
));
unawaited(NotificationOrchestratorService.instance.runPassivePass(
settings: settings,
));
await JustAudioBackground.init(
androidNotificationChannelId: 'com.jamshalat.diary.audio',
androidNotificationChannelName: 'Murattal Playback',
androidNotificationOngoing: true,
);
runApp( runApp(
const ProviderScope( const ProviderScope(
child: App(), child: App(),

View File

@@ -11,6 +11,7 @@ import flutter_local_notifications
import geolocator_apple import geolocator_apple
import just_audio import just_audio
import package_info_plus import package_info_plus
import share_plus
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
@@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@@ -249,6 +249,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -411,6 +419,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0-dev.3" version: "3.0.0-dev.3"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -629,6 +645,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
hugeicons:
dependency: "direct main"
description:
name: hugeicons
sha256: d19c0e2b57ccf455dd8ef08b84da40ae6dbba898c92960a0a0ada77df7865b8a
url: "https://pub.dev"
source: hosted
version: "1.1.5"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -677,6 +701,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.5" version: "0.10.5"
just_audio_background:
dependency: "direct main"
description:
name: just_audio_background
sha256: "3900825701164577db65337792bc122b66f9eb1245ee47e7ae8244ae5ceb8030"
url: "https://pub.dev"
source: hosted
version: "0.0.1-beta.17"
just_audio_platform_interface: just_audio_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -829,6 +861,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@@ -965,6 +1005,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.27.7" version: "0.27.7"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@@ -1210,6 +1266,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1"
url: "https://pub.dev"
source: hosted
version: "1.1.20"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@@ -48,8 +48,11 @@ dependencies:
http: ^1.2.0 http: ^1.2.0
flutter_dotenv: ^5.1.0 flutter_dotenv: ^5.1.0
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
share_plus: ^10.1.4
url_launcher: ^6.2.5 url_launcher: ^6.2.5
lucide_icons: ^0.257.0 lucide_icons: ^0.257.0
hugeicons: ^1.1.5
just_audio_background: ^0.0.1-beta.17
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -84,6 +87,19 @@ flutter:
- family: Amiri - family: Amiri
fonts: fonts:
- asset: assets/fonts/Amiri-Regular.ttf - asset: assets/fonts/Amiri-Regular.ttf
- asset: assets/fonts/Amiri-Bold.ttf # Temporary: use Regular for 700 until real Amiri-Bold.ttf is added.
# Current Amiri-Bold.ttf in repo is a placeholder text file.
- asset: assets/fonts/Amiri-Regular.ttf
weight: 700
- family: KFGQPCUthmanicHafs
fonts:
- asset: assets/fonts/KFGQPC-Uthmanic-HAFS-Regular.otf
- family: UthmanTahaNaskh
fonts:
- asset: assets/fonts/UthmanTN1-Ver10.otf
- family: ScheherazadeNew
fonts:
- asset: assets/fonts/ScheherazadeNew-Regular.ttf
weight: 400
- asset: assets/fonts/ScheherazadeNew-Bold.ttf
weight: 700 weight: 700