feat: Murattal player enhancements & prayer schedule auto-scroll
- Murattal: Spotify-style 5-button controls [Shuffle, Prev, Play, Next, Playlist] - Murattal: Animated 7-bar equalizer visualization in player circle - Murattal: Unsplash API background with frosted glass player overlay - Murattal: Transparent AppBar with backdrop blur - Murattal: Surah playlist bottom sheet with full 114 Surah list - Murattal: Auto-play disabled on screen open, enabled on navigation - Murattal: Shuffle mode for random Surah playback - Murattal: Photographer attribution per Unsplash guidelines - Dashboard: Auto-scroll prayer schedule to next active prayer - Fix: setState lifecycle errors on Reading & Murattal screens - Setup: flutter_dotenv, cached_network_image, url_launcher deps
This commit is contained in:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
33
.metadata
Normal file
33
.metadata
Normal file
@@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: macos
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: web
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
884
PRD.md
Normal file
884
PRD.md
Normal file
@@ -0,0 +1,884 @@
|
||||
# Product Requirements Document
|
||||
## Jamshalat Diary — Islamic Worship Companion App
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** March 2026
|
||||
**Platform:** Flutter (iOS + Android)
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Target Users](#2-target-users)
|
||||
3. [Design System](#3-design-system)
|
||||
4. [App Architecture](#4-app-architecture)
|
||||
5. [Navigation](#5-navigation)
|
||||
6. [Feature Specifications](#6-feature-specifications)
|
||||
- 6.1 Dashboard
|
||||
- 6.2 Prayer Calendar (Imsakiyah)
|
||||
- 6.3 Daily Checklist
|
||||
- 6.4 Dzikir
|
||||
- 6.5 Reports (Laporan)
|
||||
- 6.6 Qibla Finder
|
||||
- 6.7 Quran Reading
|
||||
- 6.8 Quran Murattal
|
||||
- 6.9 Settings
|
||||
7. [Data Model](#7-data-model)
|
||||
8. [Third-Party Dependencies](#8-third-party-dependencies)
|
||||
9. [Non-Functional Requirements](#9-non-functional-requirements)
|
||||
10. [Permissions](#10-permissions)
|
||||
11. [Out of Scope (v1.0)](#11-out-of-scope-v10)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Jamshalat Diary** is an offline-first Muslim daily worship companion app built in Flutter. It helps Muslim users track their daily prayers, worship habits, and spiritual growth with a clean, modern UI that supports both light and dark themes.
|
||||
|
||||
**Core value proposition:**
|
||||
- Never miss a prayer — real-time prayer time countdowns with Adhan/Iqamah notifications
|
||||
- Track daily worship completion — checklist for Sholat, Tilawah, Dzikir
|
||||
- Build consistent habits — weekly/monthly progress reports
|
||||
- All-in-one Islamic toolkit — Qibla compass, Quran reader, Murattal player, Dzikir counter
|
||||
|
||||
---
|
||||
|
||||
## 2. Target Users
|
||||
|
||||
**Primary:** Muslim adults (18–45) who want to improve consistency in daily worship
|
||||
**Secondary:** Parents tracking worship habits for the family
|
||||
|
||||
**User goals:**
|
||||
- Know exact prayer times for their location, offline
|
||||
- Get reminded for Adhan and Iqamah
|
||||
- Log daily ibadah (worship) completion
|
||||
- Read Quran and perform guided Dzikir
|
||||
- Find Qibla direction while traveling
|
||||
- Review worship quality over time
|
||||
|
||||
---
|
||||
|
||||
## 3. Design System
|
||||
|
||||
### 3.1 Color Tokens
|
||||
|
||||
| Token | Light Value | Dark Value | Usage |
|
||||
|---|---|---|---|
|
||||
| `primary` | `#70df20` | `#70df20` | Active states, CTAs, progress fills |
|
||||
| `background` | `#f7f8f6` | `#182111` | App background |
|
||||
| `surface` | `#ffffff` | `#1e2a14` | Cards, bottom nav |
|
||||
| `sage` | `#728764` | `#728764` | Secondary text, section labels |
|
||||
| `cream` | `#f2f4f0` | — | Dividers, borders (light mode) |
|
||||
| `on-primary` | `#0a1a00` | `#0a1a00` | Text on primary bg |
|
||||
| `text-primary` | `#1a2a0a` | `#f2f4f0` | Body text |
|
||||
| `text-secondary` | `#64748b` | `#94a3b8` | Captions, labels |
|
||||
| `error` | `#ef4444` | `#f87171` | Error states |
|
||||
| `success` | `#22c55e` | `#4ade80` | Success/completed states |
|
||||
|
||||
### 3.2 Typography
|
||||
|
||||
| Style | Font | Weight | Size | Usage |
|
||||
|---|---|---|---|---|
|
||||
| `displayLarge` | Plus Jakarta Sans | 800 (ExtraBold) | 32sp | Hero numbers (next prayer) |
|
||||
| `headlineMedium` | Plus Jakarta Sans | 700 (Bold) | 24sp | Screen titles, section headers |
|
||||
| `titleMedium` | Plus Jakarta Sans | 600 (SemiBold) | 16sp | Card titles, nav labels |
|
||||
| `bodyLarge` | Plus Jakarta Sans | 400 (Regular) | 16sp | Body text |
|
||||
| `bodySmall` | Plus Jakarta Sans | 400 (Regular) | 12sp | Captions, timestamps |
|
||||
| `labelSmall` | Plus Jakarta Sans | 700 (Bold) | 10sp | Uppercase tags, section labels |
|
||||
| `arabicBody` | Amiri | 400 (Regular) | 24sp | Quran verses, Dzikir Arabic text |
|
||||
| `arabicLarge` | Amiri | 700 (Bold) | 28sp | Surah headings in Arabic |
|
||||
|
||||
### 3.3 Spacing & Shape
|
||||
|
||||
| Token | Value | Flutter |
|
||||
|---|---|---|
|
||||
| `radiusSm` | 8dp | `BorderRadius.circular(8)` |
|
||||
| `radiusMd` | 12dp | `BorderRadius.circular(12)` |
|
||||
| `radiusLg` | 16dp | `BorderRadius.circular(16)` |
|
||||
| `radiusXl` | 24dp | `BorderRadius.circular(24)` |
|
||||
| `radiusFull` | 9999dp | `StadiumBorder()` |
|
||||
| `spacingXs` | 4dp | — |
|
||||
| `spacingSm` | 8dp | — |
|
||||
| `spacingMd` | 16dp | — |
|
||||
| `spacingLg` | 24dp | — |
|
||||
| `spacingXl` | 32dp | — |
|
||||
|
||||
### 3.4 Iconography
|
||||
|
||||
- **Library:** Material Symbols Outlined (via `material_symbols_icons` Flutter package)
|
||||
- **Filled variant:** Used for active bottom nav tab icons only
|
||||
- **Size:** 24dp default, 20dp for compact rows, 32dp for feature section icons
|
||||
|
||||
### 3.5 Dark Mode
|
||||
|
||||
- Toggled via user preference in Settings (stored locally)
|
||||
- Options: **Light**, **Dark**, **System (Auto)**
|
||||
- Implemented via Flutter `ThemeMode` — `ThemeMode.light`, `ThemeMode.dark`, `ThemeMode.system`
|
||||
- All color tokens have explicit light and dark values; no transparency hacks
|
||||
|
||||
---
|
||||
|
||||
## 4. App Architecture
|
||||
|
||||
### 4.1 Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app/
|
||||
│ ├── app.dart # MaterialApp.router + theme + locale
|
||||
│ ├── router.dart # GoRouter — shell route for bottom nav
|
||||
│ └── theme/
|
||||
│ ├── app_theme.dart # ThemeData light + dark
|
||||
│ ├── app_colors.dart # AppColors class with static consts
|
||||
│ └── app_text_styles.dart # TextTheme definitions
|
||||
├── core/
|
||||
│ ├── widgets/
|
||||
│ │ ├── bottom_nav_bar.dart
|
||||
│ │ ├── prayer_time_card.dart
|
||||
│ │ ├── section_header.dart
|
||||
│ │ ├── ios_toggle.dart
|
||||
│ │ ├── progress_bar.dart
|
||||
│ │ └── circular_progress_indicator_custom.dart
|
||||
│ ├── utils/
|
||||
│ │ ├── date_utils.dart
|
||||
│ │ ├── prayer_utils.dart # Hijri date conversion helpers
|
||||
│ │ └── arabic_utils.dart # RTL text helpers
|
||||
│ └── providers/
|
||||
│ └── theme_provider.dart # ThemeMode state via Riverpod
|
||||
├── features/
|
||||
│ ├── dashboard/
|
||||
│ │ ├── data/
|
||||
│ │ ├── domain/
|
||||
│ │ └── presentation/
|
||||
│ │ ├── dashboard_screen.dart
|
||||
│ │ └── widgets/
|
||||
│ ├── imsakiyah/
|
||||
│ ├── checklist/
|
||||
│ ├── dzikir/
|
||||
│ ├── laporan/
|
||||
│ ├── qibla/
|
||||
│ ├── quran/
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── quran_screen.dart # Surah list
|
||||
│ │ │ ├── quran_reading_screen.dart
|
||||
│ │ │ └── quran_murattal_screen.dart
|
||||
│ └── settings/
|
||||
└── data/
|
||||
├── local/
|
||||
│ ├── hive_boxes.dart # Box names constants
|
||||
│ └── adapters/ # Hive TypeAdapters
|
||||
└── services/
|
||||
├── prayer_service.dart # Adhan calculation
|
||||
├── location_service.dart # GPS + last known
|
||||
├── notification_service.dart
|
||||
└── quran_service.dart # Local JSON asset
|
||||
```
|
||||
|
||||
### 4.2 State Management
|
||||
|
||||
**Riverpod** (flutter_riverpod + riverpod_annotation + riverpod_generator)
|
||||
|
||||
- Every feature has its own `*_provider.dart`
|
||||
- Async data via `AsyncNotifierProvider`
|
||||
- UI state (loading, error, data) handled via `AsyncValue`
|
||||
- Theme mode via `StateProvider<ThemeMode>`
|
||||
|
||||
### 4.3 Local Storage
|
||||
|
||||
**Hive** for offline-first persistence:
|
||||
|
||||
| Box | Key | Value type |
|
||||
|---|---|---|
|
||||
| `settings` | String key | dynamic |
|
||||
| `checklist_items` | date string (yyyy-MM-dd) | `List<ChecklistItem>` |
|
||||
| `worship_logs` | date string | `WorshipLog` |
|
||||
| `dzikir_counters` | dzikir ID | int count |
|
||||
| `cached_prayer_times` | `lat_lng_date` | `PrayerTimes` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Navigation
|
||||
|
||||
### 5.1 Bottom Navigation Bar (5 tabs)
|
||||
|
||||
| Index | Label | Icon (inactive) | Icon (active) | Route |
|
||||
|---|---|---|---|---|
|
||||
| 0 | Home | `home_outlined` | `home` (filled) | `/` |
|
||||
| 1 | Calendar | `calendar_today_outlined` | `calendar_today` | `/imsakiyah` |
|
||||
| 2 | Checklist | `rule_outlined` | `rule` (filled) | `/checklist` |
|
||||
| 3 | Reports | `bar_chart_outlined` | `bar_chart` | `/laporan` |
|
||||
| 4 | Tools | `auto_fix_high_outlined` | `auto_fix_high` (filled) | `/tools` |
|
||||
|
||||
- Bottom nav is persistent across all 5 tabs (shell route via GoRouter)
|
||||
- Safe area padding (bottom) applied to nav bar (`pb: 24dp`)
|
||||
- Active tab: `primary` text + filled icon
|
||||
- Inactive tab: `text-secondary` color
|
||||
|
||||
### 5.2 Full Route Map
|
||||
|
||||
```
|
||||
/ → DashboardScreen
|
||||
/qibla → QiblaScreen (push, no bottom nav)
|
||||
/imsakiyah → ImsakiyahScreen
|
||||
/checklist → ChecklistScreen
|
||||
/laporan → LaporanScreen
|
||||
/tools → ToolsScreen (hub for Dzikir, Quran, Qibla)
|
||||
/tools/dzikir → DzikirScreen
|
||||
/tools/quran → QuranListScreen
|
||||
/tools/quran/:surahId → QuranReadingScreen
|
||||
/tools/quran/:surahId/murattal → QuranMurattalScreen
|
||||
/tools/qibla → QiblaScreen
|
||||
/settings → SettingsScreen (push from Dashboard header)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature Specifications
|
||||
|
||||
---
|
||||
|
||||
### 6.1 Dashboard
|
||||
|
||||
**Route:** `/`
|
||||
**Description:** App home screen showing next prayer, daily prayer schedule, checklist summary, and weekly progress.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header (sticky)**
|
||||
- Left: Circular user avatar (40dp, primary border 2dp) + greeting text ("Welcome back," / user name "Assalamu'alaikum, [Name]")
|
||||
- Right: Notification bell `IconButton` → opens notification settings
|
||||
|
||||
**Next Prayer Hero Card**
|
||||
- Full-width card, `bg-primary`, `borderRadius: 24dp`, `padding: 20dp`, `boxShadow: lg`
|
||||
- Content:
|
||||
- Label: "Next Prayer" with `schedule` icon
|
||||
- Title: `"{PrayerName} at {HH:mm}"` — `displayLarge` weight 800
|
||||
- Subtitle: `"Countdown: HH:mm:ss"` — live countdown timer updating every second
|
||||
- Actions row:
|
||||
- "View Qibla" button (`black bg`, `white text`, `explore icon`, `borderRadius: full`)
|
||||
- Volume/mute toggle button (`white/30 backdrop-blur`, `rounded-full`)
|
||||
- Decorative: white/20 blurred circle blob (top-right, size 120dp)
|
||||
|
||||
**Prayer Times Horizontal Scroll**
|
||||
- Section title: "Daily Prayer Times" + "Today" chip (`primary/10 bg`, `primary text`)
|
||||
- Horizontal `ListView.builder`, no scroll indicator, `padding: 16dp horizontal`
|
||||
- Each card: `width: 112dp`, `borderRadius: 16dp`, `border: primary/10`
|
||||
- Icon (Material Symbol): Fajr→`wb_twilight`, Dhuhr→`wb_sunny`, Asr→`filter_drama`, Maghrib→`wb_twilight`, Isha→`dark_mode`
|
||||
- Prayer name (`sm font-bold`)
|
||||
- Time string (`xs text-secondary`)
|
||||
- **Active prayer card:** `bg-primary/10`, `border-2 border-primary`, primary text color
|
||||
- **Adhan/Iqamah variant:** Active card shows notification badge (small bell icon, white circle, shadow, absolute top-right)
|
||||
|
||||
**Daily Checklist Summary Card**
|
||||
- Title: "Today's Checklist" + subtitle "{n} dari {total} Ibadah selesai"
|
||||
- Circular SVG progress indicator (48dp, `strokeWidth: 4`, `primary` stroke)
|
||||
- Percentage text centered inside circle
|
||||
- 2 preview items: "Sholat Fardhu (4 of 5)" and "Tilawah Quran (1 Juz)"
|
||||
- Completed items: `check_circle` icon in `primary` color
|
||||
- "View Full Checklist" CTA button → navigates to `/checklist`
|
||||
|
||||
**Weekly Progress Chart**
|
||||
- Title: "Weekly Progress"
|
||||
- `Container` with `bg-surface`, `borderRadius: 16dp`, `padding: 20dp`
|
||||
- 7 vertical bars (Mon–Sun): each bar is `Expanded`, `bg-primary/20`, with `primary` fill overlay proportional to completion %
|
||||
- Day labels below each bar: `10sp`, `font-bold`, `text-secondary`
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Countdown timer refreshes every 1 second via `Timer.periodic`
|
||||
- Prayer times are calculated from device location via `adhan` package on app start
|
||||
- If location unavailable, use last cached location; if none, prompt user
|
||||
- Notification badge on prayer card: shown when Adhan has been called (within current window)
|
||||
- Tapping the Next Prayer card → navigates to Imsakiyah screen
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Correct prayer times displayed for device's current GPS location
|
||||
- [ ] Countdown shows real-time seconds tick
|
||||
- [ ] Active prayer card highlighted with `primary` border and color
|
||||
- [ ] Checklist summary reflects today's actual completion state from local storage
|
||||
- [ ] Weekly chart bars reflect daily worship logs from past 7 days
|
||||
- [ ] Works fully offline (cached prayer times used when no internet)
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Prayer Calendar (Imsakiyah)
|
||||
|
||||
**Route:** `/imsakiyah`
|
||||
**Description:** Full Hijri-calendar view of prayer times, organized by month, with location selection.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header**
|
||||
- Back arrow + "Prayer Calendar" (centered, `headlineMedium`) + `more_vert` menu button
|
||||
|
||||
**Month Selector**
|
||||
- Horizontal scrolling chip row (no scrollbar)
|
||||
- Selected: `bg-primary`, `text-slate-900`, `font-semibold`, `borderRadius: full`
|
||||
- Unselected: `bg-surface`, `text-secondary`, `borderRadius: full`
|
||||
- Months shown in Hijri format: "Ramadan 1445H", "Shawwal 1445H", etc.
|
||||
|
||||
**Location Card**
|
||||
- `bg-surface`, `borderRadius: 16dp`, `border: 1dp`
|
||||
- `location_on` icon (primary) + "Your Location" label + city name (e.g., "Jakarta, Indonesia")
|
||||
- `expand_more` chevron — tapping opens city search/picker
|
||||
|
||||
**Prayer Times Table**
|
||||
- 7-column grid: Day | Fajr | Sunrise | Dhuhr | Asr | Maghrib | Isha
|
||||
- Header row: `bg-primary/10`, `10sp font-bold uppercase tracking-wider text-secondary`
|
||||
- Data rows: alternating subtle bg for readability
|
||||
- Today's row: highlighted with `primary/5` background, bold text
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Loads prayer times for entire selected month using `adhan` package
|
||||
- Location defaults to last GPS fix; editable via city search
|
||||
- City search: local bundled list of major Indonesian cities (offline), Geocoding API optional
|
||||
- Changing month or location recalculates and re-renders table
|
||||
- Scroll position resets to today's row on initial load
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Displays complete monthly prayer timetable for selected Hijri month
|
||||
- [ ] Today's row is visually highlighted
|
||||
- [ ] Month chip scroll updates table data
|
||||
- [ ] Location change triggers recalculation
|
||||
- [ ] Works offline with bundled city coordinates
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Daily Checklist
|
||||
|
||||
**Route:** `/checklist`
|
||||
**Description:** Daily worship completion tracker with custom checklist items and progress visualization.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header**
|
||||
- "Daily Worship" (`headlineMedium`) + date string ("Tuesday, 24 Oct") + calendar icon button (date picker)
|
||||
|
||||
**Progress Card**
|
||||
- `bg-slate-900 / bg-primary/10 (dark)`, `borderRadius: 16dp`, `padding: 20dp`
|
||||
- Decorative `auto_awesome` icon (64sp, opacity 10%, top-right)
|
||||
- "Today's Goal" label (`xs uppercase tracking-wider text-slate-400`)
|
||||
- Percentage: "{n}% Complete" (`displayLarge font-bold white`)
|
||||
- "{completed} / {total} Tasks" (`primary color xs`)
|
||||
- Progress bar: `h-12dp`, `bg-white/10`, `primary fill`, `borderRadius: full`
|
||||
- Motivational quote text below bar (`xs text-slate-300`)
|
||||
|
||||
**Task List**
|
||||
|
||||
Section header: "Religious Tasks" (`sm font-bold uppercase tracking-widest text-secondary`)
|
||||
|
||||
Each checklist item:
|
||||
- `Container` with `bg-surface / bg-primary/5 (dark)`, `borderRadius: 12dp`, `border: 1dp`
|
||||
- Custom checkbox: 24dp square, `border-2 border-primary/30`, `borderRadius: 6dp`
|
||||
- Checked state: `bg-primary`, white checkmark SVG inside
|
||||
- Task label: `bodyLarge font-medium`
|
||||
- Optional: sub-label (e.g., target count for Tilawah)
|
||||
|
||||
**Default checklist items (seeded on first launch):**
|
||||
|
||||
| Item | Category | Default Target |
|
||||
|---|---|---|
|
||||
| Sholat Fajr | Sholat Fardhu | 1x |
|
||||
| Sholat Dhuhr | Sholat Fardhu | 1x |
|
||||
| Sholat Asr | Sholat Fardhu | 1x |
|
||||
| Sholat Maghrib | Sholat Fardhu | 1x |
|
||||
| Sholat Isha | Sholat Fardhu | 1x |
|
||||
| Tilawah Quran | Tilawah | 1 Juz |
|
||||
| Dzikir Pagi | Dzikir | 1 session |
|
||||
| Dzikir Petang | Dzikir | 1 session |
|
||||
| Sholat Sunnah Rawatib | Sunnah | 1x |
|
||||
| Shodaqoh | Charity | 1x |
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Checklist resets daily at midnight (new date key in Hive)
|
||||
- Checking/unchecking an item updates Hive immediately (no "save" button)
|
||||
- Progress card and percentage update reactively via Riverpod
|
||||
- Motivational quotes rotate from a bundled list
|
||||
- User can add/remove/reorder checklist items (edit mode)
|
||||
- Completion data written to `worship_logs` for use by Reports feature
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Default 10 items seeded on first launch
|
||||
- [ ] Checking item updates progress bar + percentage in real time
|
||||
- [ ] Data persists across app restarts (Hive)
|
||||
- [ ] New empty checklist created automatically on date change
|
||||
- [ ] Historical completion accessible by Reports feature
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Dzikir
|
||||
|
||||
**Route:** `/tools/dzikir`
|
||||
**Description:** Guided morning and evening remembrance (Dzikir) with Arabic text, transliteration, translation, and tap counter.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header (sticky)**
|
||||
- `back arrow` + "Dzikir Pagi & Petang" (centered, `titleLarge font-bold`) + `info` icon button
|
||||
- `bg-surface/80`, `backdropFilter: blur(12dp)`, `borderBottom: 1dp primary/10`
|
||||
|
||||
**Tab Bar**
|
||||
- 2 tabs: "Pagi" (Morning) and "Petang" (Evening)
|
||||
- Active tab: `border-bottom-2 border-primary`, `text-primary`, `font-semibold`
|
||||
- Inactive tab: `text-secondary`
|
||||
|
||||
**Hero Banner**
|
||||
- `text-center`, `padding: 32dp vertical`
|
||||
- `bg-gradient(primary/5 → transparent, top → bottom)`
|
||||
- Title: "Dzikir Pagi / Petang" (`headlineMedium font-bold`)
|
||||
- Subtitle: context text in Indonesian (`bodySmall text-secondary max-width: 280dp`)
|
||||
|
||||
**Dzikir Cards (scrollable list)**
|
||||
- Each card: `bg-surface`, `borderRadius: 16dp`, `border: 1dp primary/10`, `padding: 20dp`, `margin: 8dp bottom`
|
||||
- **Arabic text** (`Amiri font`, `24sp`, `RTL direction`, `line-height: 2.0`, `text-right`)
|
||||
- **Transliteration** (`bodySmall`, `italic`, `text-secondary`, `mt: 12dp`)
|
||||
- **Translation (Indonesian)** (`bodyMedium`, `text-primary`, `mt: 8dp`)
|
||||
- **Counter row:**
|
||||
- "Dibaca: {count} / {target}x" label
|
||||
- `+` tap button (`bg-primary/10`, `text-primary`, `borderRadius: full`, `size: 40dp`)
|
||||
- Counter increments on tap; fills to target
|
||||
- When target reached: button becomes `check_circle` (green), card shows completion glow
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Default content: bundled local JSON with standard Dzikir Pagi (~20 items) and Dzikir Petang (~20 items)
|
||||
- Counter state persisted per dzikir per session in Hive (`dzikir_counters` box)
|
||||
- Counter resets daily (tied to date)
|
||||
- "Pagi" tab auto-selected between Fajr and Dhuhr; "Petang" between Maghrib and Isha; user can override
|
||||
- Info button → bottom sheet with brief explanation of dzikir practice
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Arabic text renders correctly with Amiri font, RTL direction
|
||||
- [ ] Tap counter increments and persists within the day
|
||||
- [ ] Counter resets the next day
|
||||
- [ ] Tab switches between Pagi and Petang content
|
||||
- [ ] Completion state shown when all counters reach target
|
||||
|
||||
---
|
||||
|
||||
### 6.5 Reports (Laporan)
|
||||
|
||||
**Route:** `/laporan`
|
||||
**Description:** Visual analytics of worship completion across weekly, monthly, and yearly timeframes.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header**
|
||||
- Back arrow + "Worship Quality Report" (centered) + `share` icon button
|
||||
|
||||
**Tab Bar**
|
||||
- 3 tabs: Weekly · Monthly · Yearly
|
||||
- Active: `border-bottom-2 border-primary text-primary`
|
||||
- Tab bar: `border-bottom: 1dp`
|
||||
|
||||
**Main Chart Card**
|
||||
- `bg-surface`, `borderRadius: 16dp`, `border: 1dp`, `padding: 20dp`
|
||||
- Header row:
|
||||
- Left: `analytics` icon badge (`bg-primary/10`, `primary`, `borderRadius: 12dp`, `40dp size`)
|
||||
- Center: "Daily Completion" label (`bodySmall text-secondary`) + percentage (`displayLarge font-bold`)
|
||||
- Right: trend chip: `trending_up` icon + delta % (`text-emerald-500` if positive)
|
||||
- **Bar Chart:**
|
||||
- `height: 160dp`, flex row of 7 bars (weekly) or 30/12 bars
|
||||
- Each bar: `Expanded`, `bg-primary/20` track, `bg-primary` fill overlay (proportional to % complete)
|
||||
- `borderRadius: top only (full)`
|
||||
- Day/date labels below (`10sp font-bold text-secondary uppercase`)
|
||||
- Tapping a bar → shows tooltip with exact date + completion %
|
||||
|
||||
**Summary Stats Row (below chart)**
|
||||
- 3 stat cards in a row:
|
||||
- Best streak: "{n} days" + `local_fire_department` icon
|
||||
- Average: "{n}%" + `percent` icon
|
||||
- Total completed: "{n} tasks" + `check_circle` icon
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Weekly: shows past 7 days (Mon–Sun of current week)
|
||||
- Monthly: shows all days of current month (30/31 bars, may need scroll)
|
||||
- Yearly: shows 12 months as bars
|
||||
- Data sourced from `worship_logs` Hive box (daily completion records)
|
||||
- Share button: generates a shareable image/text summary of current period stats
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Correct bar heights proportional to actual daily completion %
|
||||
- [ ] Tab switching updates chart data and labels
|
||||
- [ ] Tooltip on bar tap shows date + details
|
||||
- [ ] Summary stats calculated correctly from logs
|
||||
- [ ] Empty state when no logs exist ("Start tracking to see your progress")
|
||||
|
||||
---
|
||||
|
||||
### 6.6 Qibla Finder
|
||||
|
||||
**Route:** `/tools/qibla` or `/qibla` (accessible from Dashboard hero card)
|
||||
**Description:** Compass-based Qibla direction finder showing the direction of Mecca.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header**
|
||||
- Back arrow + "Qibla Finder" (centered) + `my_location` button (top-right, re-centers GPS)
|
||||
|
||||
**Main Content (centered)**
|
||||
- Location display: `location_on` icon (primary) + city name + Qibla degree ("142.3° from North")
|
||||
- **Compass widget:**
|
||||
- Circular compass with N/S/E/W labels
|
||||
- Rotating needle pointing to Qibla direction
|
||||
- Mosque silhouette icon overlaid at compass center (fade-to-transparent mask top)
|
||||
- Green pointer / arrow indicating Qibla direction
|
||||
- Accuracy indicator: "High accuracy" / "Low accuracy" based on sensor confidence
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Uses `flutter_qiblah` package for Qibla calculation + `flutter_compass` for device heading
|
||||
- Rotates compass ring based on device orientation (sensor stream)
|
||||
- Displays degree from North
|
||||
- If location permission denied → prompt to enable, fallback to manual city entry
|
||||
- Mosque silhouette uses a local SVG/image asset with `ShaderMask` fade
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Compass rotates smoothly with device orientation
|
||||
- [ ] Qibla arrow points to correct direction based on GPS coordinates
|
||||
- [ ] Works offline (no internet needed for calculation)
|
||||
- [ ] Graceful fallback if compass sensor unavailable
|
||||
|
||||
---
|
||||
|
||||
### 6.7 Quran Reading
|
||||
|
||||
**Route:** `/tools/quran` → `/tools/quran/:surahId`
|
||||
**Description:** Full Quran reader with Arabic text, Indonesian translation, and verse-by-verse display.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Surah List Screen (`/tools/quran`)**
|
||||
- Search bar at top
|
||||
- `ListView` of 114 Surahs: number badge + Arabic name + Latin name + verse count + Juz info
|
||||
|
||||
**Reading Screen (`/tools/quran/:surahId`)**
|
||||
|
||||
Header (sticky, `bg-surface/80 backdrop-blur`):
|
||||
- Back arrow
|
||||
- Center column: Surah name (Arabic, `Amiri`) + "Juz {n}" label + verse count
|
||||
- `more_vert` menu (bookmarks, jump to verse, settings)
|
||||
|
||||
**Bismillah banner:** Centered, Arabic font, before verse 1 (except Surah 9)
|
||||
|
||||
**Verse Cards:**
|
||||
- Each verse: `Container`, `bg-surface`, `borderRadius: 12dp`, `padding: 16dp`, `border: 1dp`
|
||||
- Verse number badge: small circle, `bg-primary/10`, `primary text`, left-aligned
|
||||
- **Arabic text:** `Amiri`, `28sp`, `RTL`, `line-height: 2.2`, right-aligned, full width
|
||||
- **Transliteration** (optional, toggled in settings): `bodySmall italic text-secondary`
|
||||
- **Indonesian translation:** `bodyMedium text-primary`, left-aligned, `mt: 8dp`
|
||||
- Verse action row: `bookmark`, `share`, `play` (Murattal) icons
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Quran data: bundled local JSON asset (`assets/quran/quran_id.json`) — 114 surahs, Arabic + Indonesian translation
|
||||
- Reading position persisted per Surah (last verse read)
|
||||
- Bookmarks stored in Hive
|
||||
- "Play" icon on verse → navigates to Murattal screen for that Surah starting at that verse
|
||||
- Font size adjustable via settings (stored preference)
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] All 114 Surahs accessible
|
||||
- [ ] Arabic text renders with Amiri font, correct RTL layout
|
||||
- [ ] Indonesian translation displayed below each verse
|
||||
- [ ] Reading position saved across app restarts
|
||||
- [ ] Bookmarking a verse persists in Hive
|
||||
- [ ] Works fully offline
|
||||
|
||||
---
|
||||
|
||||
### 6.8 Quran Murattal
|
||||
|
||||
**Route:** `/tools/quran/:surahId/murattal`
|
||||
**Description:** Audio recitation player synchronized with Quran text display.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header:** Same as Quran Reading (Surah name + Juz info)
|
||||
|
||||
**Quran Text:** Same as Reading screen — synchronized verse highlight follows audio playback
|
||||
|
||||
**Audio Player (bottom persistent panel)**
|
||||
- Reciter name + surah name
|
||||
- Progress slider (current position / total duration)
|
||||
- `skip_previous` | `replay_10` | Play/Pause (`play_circle` / `pause_circle`, 56dp) | `forward_10` | `skip_next`
|
||||
- Playback speed selector (0.75x, 1x, 1.25x, 1.5x)
|
||||
- Sleep timer button
|
||||
|
||||
#### Behavior
|
||||
|
||||
- Audio source: Bundled MP3s for commonly used Surahs (short ones: Al-Fatihah, Juz Amma) — OR streamed from a free Quran audio API (e.g., mp3quran.net)
|
||||
- Currently playing verse highlighted with `primary/10 bg` + left border accent
|
||||
- Auto-scrolls to current verse during playback
|
||||
- Background audio playback (continues when app backgrounded)
|
||||
- Notification media controls shown in system tray during playback
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] Audio plays and pauses correctly
|
||||
- [ ] Current verse highlighted in sync with audio (best effort with timed segments)
|
||||
- [ ] Background playback works (audio continues when screen off)
|
||||
- [ ] System notification with media controls displayed during playback
|
||||
- [ ] Playback speed adjustment works
|
||||
|
||||
---
|
||||
|
||||
### 6.9 Settings
|
||||
|
||||
**Route:** `/settings`
|
||||
**Description:** User profile, notification preferences, display preferences, and app info.
|
||||
|
||||
#### UI Components
|
||||
|
||||
**Header (sticky)**
|
||||
- Back arrow + "Settings" (`titleLarge font-bold`) + no right action
|
||||
|
||||
**Profile Section**
|
||||
- Avatar: 64dp circle, `bg-primary/20`, `border-2 border-primary`, initials or photo
|
||||
- Name: (`titleMedium font-bold`)
|
||||
- Email: (`bodySmall text-secondary`)
|
||||
- Edit button (`text-primary`, `edit` icon, top-right of card) → edit name/email inline
|
||||
|
||||
**Notification Settings Group**
|
||||
|
||||
Label: "NOTIFICATIONS" (`labelSmall uppercase tracking-wider sage color`)
|
||||
|
||||
Rows (separated by thin divider):
|
||||
- **Adhan Notification** — per prayer toggle: Fajr, Dhuhr, Asr, Maghrib, Isha
|
||||
- **Iqamah Reminder** — offset in minutes (default: 10 min, stepper or picker)
|
||||
- **Daily Checklist Reminder** — time picker (default: 9:00 AM)
|
||||
|
||||
Each row: `leading icon (bg-primary/10, rounded-lg, 40dp)` + `label + subtitle` + **iOS-style toggle**
|
||||
|
||||
**iOS-style toggle spec:**
|
||||
- Size: `51dp × 31dp`
|
||||
- Track: `bg-cream (off)` / `bg-primary (on)`, `borderRadius: full`
|
||||
- Thumb: `27dp` white circle, `shadow-md`, animates left↔right on toggle
|
||||
- Implemented via `AnimatedContainer` + `GestureDetector`
|
||||
|
||||
**Display Settings Group**
|
||||
|
||||
Label: "DISPLAY"
|
||||
|
||||
Rows:
|
||||
- **Dark Mode**: Light / Dark / Auto (3-way segmented control or cycle toggle)
|
||||
- **Font Size**: Small / Medium / Large (affects Quran + Dzikir text)
|
||||
- **Language**: Indonesian / English (UI language, not Quran translation)
|
||||
|
||||
**About Group**
|
||||
|
||||
Label: "ABOUT"
|
||||
|
||||
Rows:
|
||||
- App Version: "Jamshalat Diary v1.0.0"
|
||||
- Privacy Policy (launches in-app browser)
|
||||
- Rate the App (links to store)
|
||||
- Contact / Feedback
|
||||
|
||||
#### Behavior
|
||||
|
||||
- All settings persisted in Hive `settings` box immediately on change
|
||||
- Dark mode change applies instantly (no restart needed) via ThemeMode Riverpod provider
|
||||
- Notification toggles register/unregister `flutter_local_notifications` channels
|
||||
- Iqamah offset: default 10 minutes, adjustable per prayer
|
||||
- Profile name/email stored locally only (no backend account system in v1.0)
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- [ ] All toggle states persisted and restored on restart
|
||||
- [ ] Dark mode applies instantly with animation
|
||||
- [ ] Adhan/Iqamah notifications schedule correctly based on calculated prayer times
|
||||
- [ ] Notifications cancel when their toggle is turned off
|
||||
- [ ] iOS-style toggle animation is smooth (no jank)
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model
|
||||
|
||||
### 7.1 Hive Boxes & Schemas
|
||||
|
||||
```dart
|
||||
// Settings box (key-value)
|
||||
class AppSettings {
|
||||
String userName; // 'Alex Rivers'
|
||||
String userEmail; // 'alex@example.com'
|
||||
ThemeMode themeMode; // ThemeMode.system
|
||||
double arabicFontSize; // 24.0
|
||||
String uiLanguage; // 'id' | 'en'
|
||||
Map<String, bool> adhanEnabled; // {'fajr': true, 'dhuhr': true, ...}
|
||||
Map<String, int> iqamahOffset; // {'fajr': 15, 'dhuhr': 10, ...} in minutes
|
||||
DateTime? checklistReminderTime;
|
||||
double? lastLat;
|
||||
double? lastLng;
|
||||
String? lastCityName;
|
||||
}
|
||||
|
||||
// Checklist box
|
||||
@HiveType(typeId: 1)
|
||||
class ChecklistItem {
|
||||
String id; // UUID
|
||||
String title; // 'Sholat Fajr'
|
||||
String category; // 'sholat_fardhu'
|
||||
String? subtitle; // '1 Juz'
|
||||
int sortOrder;
|
||||
bool isCustom; // false for default items
|
||||
}
|
||||
|
||||
// Daily log box (keyed by 'yyyy-MM-dd')
|
||||
@HiveType(typeId: 2)
|
||||
class DailyWorshipLog {
|
||||
String date; // '2026-03-06'
|
||||
Map<String, bool> completedItems; // {'item_id': true}
|
||||
int totalItems;
|
||||
int completedCount;
|
||||
double completionPercent;
|
||||
}
|
||||
|
||||
// Dzikir counter box (keyed by 'dzikir_id:yyyy-MM-dd')
|
||||
@HiveType(typeId: 3)
|
||||
class DzikirCounter {
|
||||
String dzikirId;
|
||||
String date;
|
||||
int count;
|
||||
int target;
|
||||
}
|
||||
|
||||
// Bookmarks box
|
||||
@HiveType(typeId: 4)
|
||||
class QuranBookmark {
|
||||
int surahId;
|
||||
int verseId;
|
||||
String surahName;
|
||||
String verseText; // Arabic snippet
|
||||
DateTime savedAt;
|
||||
}
|
||||
|
||||
// Cached prayer times box (keyed by 'lat_lng_yyyy-MM-dd')
|
||||
@HiveType(typeId: 5)
|
||||
class CachedPrayerTimes {
|
||||
String key;
|
||||
double lat;
|
||||
double lng;
|
||||
String date;
|
||||
DateTime fajr;
|
||||
DateTime sunrise;
|
||||
DateTime dhuhr;
|
||||
DateTime asr;
|
||||
DateTime maghrib;
|
||||
DateTime isha;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Third-Party Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---|---|---|
|
||||
| `flutter_riverpod` | ^2.x | State management |
|
||||
| `riverpod_annotation` | ^2.x | Code gen for providers |
|
||||
| `go_router` | ^13.x | Declarative navigation |
|
||||
| `hive_flutter` | ^1.x | Local key-value storage |
|
||||
| `hive_generator` | ^2.x | Hive TypeAdapter codegen |
|
||||
| `adhan` | ^1.x | Prayer time calculation (offline) |
|
||||
| `geolocator` | ^11.x | GPS location |
|
||||
| `geocoding` | ^3.x | Reverse geocoding (city name) |
|
||||
| `flutter_qiblah` | ^2.x | Qibla direction calculation |
|
||||
| `flutter_compass` | ^0.x | Device compass heading |
|
||||
| `flutter_local_notifications` | ^17.x | Adhan + Iqamah notifications |
|
||||
| `just_audio` | ^0.x | Quran Murattal audio playback |
|
||||
| `audio_service` | ^0.x | Background audio + media controls |
|
||||
| `google_fonts` | ^6.x | Plus Jakarta Sans |
|
||||
| `material_symbols_icons` | ^4.x | Material Symbols icon set |
|
||||
| `build_runner` | ^2.x | Code generation runner |
|
||||
|
||||
**Quran Data Sources:**
|
||||
- Arabic text + Indonesian translation: bundled as `assets/quran/quran_id.json` (local, offline)
|
||||
- Audio (Murattal): streamed from `https://server6.mp3quran.net/` (Mishari Al-Afasy) or bundled short Surahs
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-Functional Requirements
|
||||
|
||||
### 9.1 Performance
|
||||
|
||||
- App cold start: < 2 seconds on mid-range devices (Snapdragon 700 series / Apple A14)
|
||||
- Bottom nav tab switch: < 150ms
|
||||
- Prayer time calculation: < 50ms (synchronous, offline)
|
||||
- Quran verse list render: use `ListView.builder` (lazy loading), never `ListView` with all children
|
||||
|
||||
### 9.2 Offline-First
|
||||
|
||||
- Core features work with no internet: Dashboard, Checklist, Dzikir, Reports, Qibla, Quran Reading, Settings
|
||||
- Quran Murattal: short Surahs bundled; longer Surahs require connectivity (graceful fallback shown)
|
||||
- Prayer times: cached per location+date in Hive; recalculated on location change
|
||||
|
||||
### 9.3 Accessibility
|
||||
|
||||
- Minimum touch target: 48×48dp for all interactive elements
|
||||
- All icons have semantic labels (`Semantics(label: ...)`)
|
||||
- Arabic text has `textDirection: TextDirection.rtl` explicitly set
|
||||
- Sufficient contrast ratios for both light and dark themes (WCAG AA)
|
||||
|
||||
### 9.4 Dark Mode
|
||||
|
||||
- All screens must look correct in both light and dark mode
|
||||
- No hardcoded `Colors.white` or `Colors.black` — use `AppColors` tokens only
|
||||
- Theme transitions are animated (Material 3 `AnimatedTheme`)
|
||||
|
||||
### 9.5 Localization
|
||||
|
||||
- v1.0: Indonesian (id) as primary language, English (en) as secondary
|
||||
- Use `flutter_localizations` + `intl` package for date/number formatting
|
||||
- All UI strings in `AppLocalizations` (ARB files); no hardcoded Indonesian strings in widgets
|
||||
|
||||
---
|
||||
|
||||
## 10. Permissions
|
||||
|
||||
| Permission | Reason | Timing |
|
||||
|---|---|---|
|
||||
| `ACCESS_FINE_LOCATION` (Android) / `NSLocationWhenInUseUsageDescription` (iOS) | Prayer time calculation, Qibla direction | On first Dashboard load |
|
||||
| `POST_NOTIFICATIONS` (Android 13+) | Adhan and Iqamah notifications | On Settings → Notifications first toggle |
|
||||
| `SCHEDULE_EXACT_ALARM` (Android 12+) | Exact Adhan notification timing | When notification enabled |
|
||||
| Background audio (iOS: `audio` background mode) | Murattal background playback | On Murattal first play |
|
||||
|
||||
**Permission UX:**
|
||||
- Never request permission without explaining why first (show rationale bottom sheet)
|
||||
- Graceful degradation: if location denied → manual city picker; if notifications denied → remind once, then respect
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of Scope (v1.0)
|
||||
|
||||
The following features are **explicitly excluded** from v1.0 to keep scope focused:
|
||||
|
||||
- User accounts / cloud sync (no backend, local-only)
|
||||
- Social features (sharing worship progress to social media, leaderboards)
|
||||
- Full Quran audio library (only short Surahs bundled/streamed)
|
||||
- Quran memorization (Hafalan) mode
|
||||
- Hijri calendar widget beyond Imsakiyah
|
||||
- Community / forum features
|
||||
- Multi-user / family tracking
|
||||
- Wear OS / watchOS companion
|
||||
- Widgets (home screen app widget)
|
||||
- Apple Watch prayer time complication
|
||||
- In-app purchases or subscriptions
|
||||
- Push notifications from a server (only local scheduled notifications)
|
||||
|
||||
---
|
||||
|
||||
*PRD v1.0 — Jamshalat Diary — March 2026*
|
||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# jamshalat_diary
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
341
TASKLIST.md
Normal file
341
TASKLIST.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Jamshalat Diary — Phase-Based Implementation Tasklist
|
||||
|
||||
**Project:** Jamshalat Diary Flutter App
|
||||
**PRD Reference:** `PRD.md`
|
||||
**Design Reference:** `stitch/` folder (18 screens)
|
||||
**Last Updated:** March 2026
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Document
|
||||
|
||||
- **Status legend:** `[ ]` Not started · `[~]` In progress · `[x]` Done · `[-]` Skipped/deferred
|
||||
- **Dependency notation:** Tasks marked with `⚠️ depends on: [task ref]` must not start until that task is complete
|
||||
- **Assignable units:** Each task is atomic enough to delegate to a single agent/developer
|
||||
- **Phase gates:** Do not start a phase until all `[x]` required tasks in the previous phase are complete
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundation
|
||||
> **Goal:** Runnable Flutter app with navigation shell, design system, and theme engine wired up.
|
||||
> **Gate to Phase 2:** App boots, bottom nav switches tabs, light/dark toggle works.
|
||||
|
||||
### 1.1 Project Bootstrap
|
||||
- [ ] **1.1.1** Create Flutter project: `flutter create jamshalat_diary --org com.jamshalat --platforms android,ios`
|
||||
- [ ] **1.1.2** Add all dependencies to `pubspec.yaml` (see PRD §8 — Third-Party Dependencies)
|
||||
- [ ] **1.1.3** Run `flutter pub get` and verify zero conflicts
|
||||
- [ ] **1.1.4** Configure `analysis_options.yaml` with strict linting rules (`flutter_lints` + `prefer_const_constructors`)
|
||||
- [ ] **1.1.5** Set up folder structure as defined in PRD §4.1 (create all `lib/` directories and placeholder files)
|
||||
- [ ] **1.1.6** Add font assets to `pubspec.yaml`: Google Fonts (Plus Jakarta Sans via `google_fonts` package) + Amiri (bundled TTF in `assets/fonts/`)
|
||||
- [ ] **1.1.7** Add asset directories to `pubspec.yaml`: `assets/fonts/`, `assets/quran/`, `assets/dzikir/`, `assets/images/`
|
||||
- [ ] **1.1.8** Configure Android `minSdkVersion: 21`, `targetSdkVersion: 34` in `android/app/build.gradle`
|
||||
- [ ] **1.1.9** Configure iOS deployment target: 13.0 in `ios/Podfile`
|
||||
|
||||
### 1.2 Design System
|
||||
- [ ] **1.2.1** Create `lib/app/theme/app_colors.dart` — all color tokens from PRD §3.1 (light + dark values as static consts)
|
||||
- [ ] **1.2.2** Create `lib/app/theme/app_text_styles.dart` — full `TextTheme` from PRD §3.2 (Plus Jakarta Sans weights, Amiri definitions)
|
||||
- [ ] **1.2.3** Create `lib/app/theme/app_theme.dart` — `ThemeData` for light and dark using tokens from 1.2.1 + 1.2.2; Material 3 enabled
|
||||
- [ ] **1.2.4** Create `lib/core/widgets/ios_toggle.dart` — custom iOS-style toggle widget (51×31dp, animated thumb, `AnimatedContainer` + `GestureDetector`) per PRD §6.9
|
||||
- [ ] **1.2.5** Create `lib/core/widgets/section_header.dart` — reusable section label widget (uppercase, tracking-wider, sage color)
|
||||
- [ ] **1.2.6** Create `lib/core/widgets/progress_bar.dart` — reusable linear progress bar (primary fill, configurable height + borderRadius)
|
||||
- [ ] **1.2.7** Verify `AppColors` against all 18 stitch screens — no hardcoded colors in any widget
|
||||
|
||||
### 1.3 Navigation Shell
|
||||
- [ ] **1.3.1** Add `go_router` and set up `lib/app/router.dart` with `ShellRoute` for bottom nav tabs
|
||||
- [ ] **1.3.2** Define all routes from PRD §5.2 in `router.dart` (top-level + nested)
|
||||
- [ ] **1.3.3** Create `lib/core/widgets/bottom_nav_bar.dart` — 5-tab nav bar per PRD §5.1 spec (active/inactive states, Material Symbols filled/outlined)
|
||||
- [ ] **1.3.4** Create stub screens for all 9 features (empty `Scaffold` + `AppBar` with feature name) so routing is testable
|
||||
- [ ] **1.3.5** Create `lib/app/app.dart` — `MaterialApp.router` wired to `router.dart` + `themeMode` from Riverpod provider
|
||||
|
||||
### 1.4 Theme Engine
|
||||
- [ ] **1.4.1** Create `lib/core/providers/theme_provider.dart` — `StateProvider<ThemeMode>` initialized from Hive settings
|
||||
- [ ] **1.4.2** Wrap `MaterialApp` in `ProviderScope`; consume `themeProvider` for `themeMode`
|
||||
- [ ] **1.4.3** Verify light ↔ dark toggle visually applies instantly on all stub screens
|
||||
- [ ] **1.4.4** Create `lib/main.dart` — initializes Hive, runs `ProviderScope` + `App`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Core Data Layer
|
||||
> **Goal:** All services and local storage ready. Features can read/write data.
|
||||
> **Gate to Phase 3:** Prayer times calculate correctly for Jakarta coords, Hive boxes open without errors, notifications schedule in emulator.
|
||||
> ⚠️ Depends on: Phase 1 complete
|
||||
|
||||
### 2.1 Hive Setup
|
||||
- [ ] **2.1.1** Create all 6 Hive `@HiveType` model classes from PRD §7.1: `AppSettings`, `ChecklistItem`, `DailyWorshipLog`, `DzikirCounter`, `QuranBookmark`, `CachedPrayerTimes`
|
||||
- [ ] **2.1.2** Run `build_runner` to generate Hive `TypeAdapter` files (`*.g.dart`)
|
||||
- [ ] **2.1.3** Create `lib/data/local/hive_boxes.dart` — box name constants + `initHive()` function that opens all boxes
|
||||
- [ ] **2.1.4** Call `initHive()` in `main.dart` before `runApp()`
|
||||
- [ ] **2.1.5** Seed default `AppSettings` on first launch (if settings box is empty)
|
||||
- [ ] **2.1.6** Seed default 10 `ChecklistItem` entries on first launch (per PRD §6.3 default items table)
|
||||
|
||||
### 2.2 Prayer Time Service
|
||||
- [ ] **2.2.1** Create `lib/data/services/prayer_service.dart` using `adhan` package
|
||||
- Method: `PrayerTimes getPrayerTimes(double lat, double lng, DateTime date)`
|
||||
- Calculation method: Muslim World League (default), configurable in settings
|
||||
- Returns: Fajr, Sunrise, Dhuhr, Asr, Maghrib, Isha as `DateTime`
|
||||
- [ ] **2.2.2** Implement cache logic: before calculating, check `CachedPrayerTimes` Hive box for existing `lat_lng_date` key; write result to box after calculation
|
||||
- [ ] **2.2.3** Create `lib/features/dashboard/data/prayer_times_provider.dart` — `AsyncNotifierProvider` that fetches location then returns today's `PrayerTimes`
|
||||
- [ ] **2.2.4** Unit test: `getPrayerTimes(lat: -6.2088, lng: 106.8456, date: 2026-03-06)` → verify Maghrib ≈ 18:05 WIB
|
||||
|
||||
### 2.3 Location Service
|
||||
- [ ] **2.3.1** Create `lib/data/services/location_service.dart`
|
||||
- Method: `Future<Position?> getCurrentLocation()` — requests permission, gets GPS fix
|
||||
- Method: `Position? getLastKnownLocation()` — reads from `AppSettings` Hive box
|
||||
- Method: `Future<String> getCityName(double lat, double lng)` — reverse geocode via `geocoding` package
|
||||
- [ ] **2.3.2** Implement fallback chain: GPS → last saved → manual city picker
|
||||
- [ ] **2.3.3** Save successful GPS fix to `AppSettings` (lat, lng, cityName) after each successful fetch
|
||||
- [ ] **2.3.4** Add `ACCESS_FINE_LOCATION` permission to Android `AndroidManifest.xml`
|
||||
- [ ] **2.3.5** Add `NSLocationWhenInUseUsageDescription` to iOS `Info.plist`
|
||||
|
||||
### 2.4 Notification Service
|
||||
- [ ] **2.4.1** Create `lib/data/services/notification_service.dart` using `flutter_local_notifications`
|
||||
- Method: `Future<void> init()` — initializes channels (Android: high-priority Adhan channel)
|
||||
- Method: `Future<void> scheduleAdhan(Prayer prayer, DateTime time)` — schedules exact alarm
|
||||
- Method: `Future<void> scheduleIqamah(Prayer prayer, DateTime adhanTime, int offsetMinutes)` — schedules iqamah reminder
|
||||
- Method: `Future<void> cancelAll()` — cancels all pending notifications
|
||||
- Method: `Future<void> rescheduleAll(PrayerTimes times, AppSettings settings)` — cancels + re-schedules based on current settings
|
||||
- [ ] **2.4.2** Add `SCHEDULE_EXACT_ALARM` + `POST_NOTIFICATIONS` permissions to Android manifest
|
||||
- [ ] **2.4.3** Add iOS background modes for notifications in `Info.plist`
|
||||
- [ ] **2.4.4** Call `rescheduleAll()` on app start and whenever settings or location changes
|
||||
- [ ] **2.4.5** Manual test: enable Fajr notification in settings → verify notification fires at correct time in emulator
|
||||
|
||||
### 2.5 Quran Data Service
|
||||
- [ ] **2.5.1** Source or create `assets/quran/quran_id.json` — 114 Surahs, each entry: `{ id, nameArabic, nameLatin, verseCount, juzStart, verses: [{id, arabic, transliteration, translation_id}] }`
|
||||
- [ ] **2.5.2** Create `lib/data/services/quran_service.dart`
|
||||
- Method: `Future<List<Surah>> getAllSurahs()` — loads + parses JSON asset (cached in memory after first load)
|
||||
- Method: `Future<Surah> getSurah(int id)` — returns single surah with all verses
|
||||
- [ ] **2.5.3** Verify JSON loads correctly; log verse count for Surah 2 (Al-Baqarah) = 286 verses
|
||||
|
||||
### 2.6 Dzikir Data Service
|
||||
- [ ] **2.6.1** Create `assets/dzikir/dzikir_pagi.json` and `assets/dzikir/dzikir_petang.json` — each entry: `{ id, arabic, transliteration, translation, target_count, source }`
|
||||
- [ ] **2.6.2** Create `lib/data/services/dzikir_service.dart`
|
||||
- Method: `Future<List<DzikirItem>> getDzikir(DzikirType type)` — loads from JSON asset
|
||||
- Method: `Map<String, int> getCountersForDate(String date)` — reads from Hive `dzikir_counters` box
|
||||
- Method: `void increment(String dzikirId, String date)` — writes to Hive
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Dashboard & Prayer Calendar
|
||||
> **Goal:** The two primary daily-use screens are fully functional.
|
||||
> **Gate to Phase 4:** Dashboard shows live countdown, correct prayer times, checklist summary. Imsakiyah shows full monthly table.
|
||||
> ⚠️ Depends on: Phase 2 complete
|
||||
|
||||
### 3.1 Dashboard Screen
|
||||
- [ ] **3.1.1** Build `DashboardScreen` sticky header: avatar + greeting + notification bell (taps → Settings)
|
||||
- [ ] **3.1.2** Build Next Prayer Hero Card: bg-primary card, prayer name+time (displayLarge), live countdown timer widget
|
||||
- Countdown: `StreamBuilder` on `Stream.periodic(Duration(seconds: 1))` computing time to next prayer
|
||||
- [ ] **3.1.3** Build Adhan/Iqamah badge: notification bell icon absolutely positioned on active prayer card (shown when Adhan has been called, hidden otherwise)
|
||||
- [ ] **3.1.4** Build "View Qibla" button on hero card → navigates to `/tools/qibla`
|
||||
- [ ] **3.1.5** Build Prayer Times horizontal scroll: `ListView.builder` horizontal, 5 prayer cards (Fajr–Isha), active card highlighted
|
||||
- [ ] **3.1.6** Build Today's Checklist Summary Card: circular SVG progress + 2 preview items + "View Full Checklist" CTA
|
||||
- [ ] **3.1.7** Build Weekly Progress bar chart: 7 bars (Mon–Sun), data from `worship_logs` Hive box
|
||||
- [ ] **3.1.8** Wire `prayer_times_provider` to all prayer time displays
|
||||
- [ ] **3.1.9** Wire checklist summary to today's `DailyWorshipLog` from Hive
|
||||
- [ ] **3.1.10** Verify screen matches `stitch/dashboard_active_nav/screen.png` visually
|
||||
|
||||
### 3.2 Prayer Calendar (Imsakiyah) Screen
|
||||
- [ ] **3.2.1** Build Imsakiyah screen header: back + "Prayer Calendar" + `more_vert`
|
||||
- [ ] **3.2.2** Build Hijri month selector: horizontal scroll chip row; compute current + surrounding Hijri months
|
||||
- [ ] **3.2.3** Build location card: shows current city name + `expand_more` tapping opens city search bottom sheet
|
||||
- [ ] **3.2.4** Build city search bottom sheet: `TextField` + `ListView` of bundled Indonesian cities (local JSON asset)
|
||||
- [ ] **3.2.5** Build prayer times table: `GridView` 7-column, header row, data rows for all days of selected Hijri month
|
||||
- [ ] **3.2.6** Highlight today's row with `primary/5` background
|
||||
- [ ] **3.2.7** Wire month selector + location to `PrayerService` — recalculate on change
|
||||
- [ ] **3.2.8** Auto-scroll to today's row on screen open
|
||||
- [ ] **3.2.9** Verify screen matches `stitch/imsakiyah_active_nav/screen.png` visually
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Worship Tracking
|
||||
> **Goal:** Checklist, Dzikir, and Reports fully functional with persistent data.
|
||||
> **Gate to Phase 5:** Checklist persists across restarts, Dzikir counter works, Reports chart renders from real log data.
|
||||
> ⚠️ Depends on: Phase 2 complete (data layer), Phase 3 (worship_logs written by Dashboard)
|
||||
|
||||
### 4.1 Daily Checklist Screen
|
||||
- [ ] **4.1.1** Build Checklist header: "Daily Worship" + date string + calendar icon (date picker for viewing past days)
|
||||
- [ ] **4.1.2** Build Progress Card: dark bg, `auto_awesome` decoration, percentage text, progress bar, motivational quote
|
||||
- [ ] **4.1.3** Build task list: `ListView` of `ChecklistItem` widgets with custom checkbox
|
||||
- [ ] **4.1.4** Implement custom checkbox widget: 24dp, primary border, animated check SVG on tap
|
||||
- [ ] **4.1.5** Wire tap → update `DailyWorshipLog` in Hive → Riverpod provider invalidate → progress card updates reactively
|
||||
- [ ] **4.1.6** Implement daily reset: on date change, create new empty `DailyWorshipLog` for new date
|
||||
- [ ] **4.1.7** Write completion data to `worship_logs` box (for Reports feature to consume)
|
||||
- [ ] **4.1.8** Verify screen matches `stitch/checklist_active_nav/screen.png` and `stitch/checklist_dark_mode/screen.png`
|
||||
|
||||
### 4.2 Dzikir Screen
|
||||
- [ ] **4.2.1** Build Dzikir header: back + "Dzikir Pagi & Petang" + info button (bottom sheet explanation)
|
||||
- [ ] **4.2.2** Build Pagi/Petang tab bar with animated underline indicator
|
||||
- [ ] **4.2.3** Build hero banner: gradient bg + title + subtitle
|
||||
- [ ] **4.2.4** Build Dzikir card: Arabic text (Amiri, RTL, 24sp), transliteration (italic), translation, counter row
|
||||
- [ ] **4.2.5** Implement tap counter: `+` button → increments count in Hive → rebuilds counter row reactively
|
||||
- [ ] **4.2.6** Implement completion state: when `count >= target`, button becomes `check_circle`, card shows subtle primary glow
|
||||
- [ ] **4.2.7** Implement smart tab pre-selection: Pagi between Fajr–Dhuhr, Petang between Maghrib–Isha
|
||||
- [ ] **4.2.8** Verify Arabic text renders correctly RTL, no overflow, no visual glitches
|
||||
- [ ] **4.2.9** Verify screen matches `stitch/dzikir_active_nav/screen.png` and `stitch/dzikir_dark_mode/screen.png`
|
||||
|
||||
### 4.3 Reports (Laporan) Screen
|
||||
- [ ] **4.3.1** Build Reports header: back + "Worship Quality Report" + share button
|
||||
- [ ] **4.3.2** Build Weekly/Monthly/Yearly tab bar
|
||||
- [ ] **4.3.3** Build main chart card: analytics badge + completion % + trend chip
|
||||
- [ ] **4.3.4** Build bar chart widget: custom `CustomPainter` or `Column`+`Expanded` bars, proportional heights from log data
|
||||
- [ ] **4.3.5** Implement tap-on-bar tooltip: `OverlayEntry` or `Tooltip` showing date + exact % on tap
|
||||
- [ ] **4.3.6** Build summary stats row: Best streak, Average %, Total completed
|
||||
- [ ] **4.3.7** Implement streak calculation: consecutive days with completion > 0% from `worship_logs`
|
||||
- [ ] **4.3.8** Implement share button: generate shareable text summary (no image generation in v1.0)
|
||||
- [ ] **4.3.9** Build empty state: illustration + "Start tracking to see your progress" when no logs exist
|
||||
- [ ] **4.3.10** Verify screen matches `stitch/laporan_active_nav/screen.png` and `stitch/laporan_dark_mode/screen.png`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Islamic Tools
|
||||
> **Goal:** Qibla, Quran Reader, and Murattal player fully functional.
|
||||
> **Gate to Phase 6:** Qibla compass rotates with device, Quran displays all 114 Surahs, audio plays in background.
|
||||
> ⚠️ Depends on: Phase 2 complete (data layer)
|
||||
|
||||
### 5.1 Qibla Finder Screen
|
||||
- [ ] **5.1.1** Build Qibla header: back + "Qibla Finder" + `my_location` button
|
||||
- [ ] **5.1.2** Build location + degree display: city name + `"{n}°} from North"` label
|
||||
- [ ] **5.1.3** Build compass widget: circular ring with N/S/E/W labels, `AnimatedRotation` driven by `flutter_compass` stream
|
||||
- [ ] **5.1.4** Build Qibla arrow: overlaid pointer that stays fixed to calculated Qibla bearing while compass ring rotates
|
||||
- [ ] **5.1.5** Build mosque silhouette overlay: `ShaderMask` with gradient mask (fade-to-transparent at top)
|
||||
- [ ] **5.1.6** Build accuracy indicator: "High accuracy" / "Low accuracy" label based on sensor data
|
||||
- [ ] **5.1.7** Build permission-denied state: explanation card + "Open Settings" button
|
||||
- [ ] **5.1.8** Verify compass direction is correct for Jakarta coordinates (Qibla ≈ 295° from North)
|
||||
|
||||
### 5.2 Quran List Screen
|
||||
- [ ] **5.2.1** Build Quran list screen header: search bar at top
|
||||
- [ ] **5.2.2** Build Surah list: `ListView.builder` of 114 items
|
||||
- Each item: number badge (primary/10 bg) + Arabic name (Amiri) + Latin name + verse count + Juz
|
||||
- [ ] **5.2.3** Implement search filter: filters by Latin name or Surah number in real time
|
||||
|
||||
### 5.3 Quran Reading Screen
|
||||
- [ ] **5.3.1** Build reading screen sticky header: back + Surah name (Arabic, Amiri) + Juz info + `more_vert`
|
||||
- [ ] **5.3.2** Build Bismillah banner (shown for all Surahs except Surah 9 — At-Tawbah)
|
||||
- [ ] **5.3.3** Build verse card: verse number badge + Arabic text (Amiri, 28sp, RTL) + transliteration + translation
|
||||
- [ ] **5.3.4** Build verse action row: `bookmark`, `share`, `play` icons per verse
|
||||
- [ ] **5.3.5** Implement bookmarking: tap bookmark icon → save `QuranBookmark` to Hive → icon toggles to filled
|
||||
- [ ] **5.3.6** Implement reading position persistence: save last scroll position (verse index) to Hive per Surah
|
||||
- [ ] **5.3.7** Wire `play` icon on verse → navigate to `/tools/quran/:surahId/murattal` starting at that verse
|
||||
- [ ] **5.3.8** Verify Arabic text is RTL, no clipping, line-height comfortable (2.2 per PRD)
|
||||
- [ ] **5.3.9** Verify screen matches `stitch/quran_reading_active_nav/screen.png` and `stitch/quran_dark_mode/screen.png`
|
||||
|
||||
### 5.4 Quran Murattal Screen
|
||||
- [ ] **5.4.1** Initialize `just_audio` player + `audio_service` for background playback
|
||||
- [ ] **5.4.2** Build Murattal screen: inherits Quran Reading layout + adds audio player panel at bottom
|
||||
- [ ] **5.4.3** Build audio player panel: reciter name + progress slider + transport controls (`skip_previous`, `replay_10`, play/pause, `forward_10`, `skip_next`)
|
||||
- [ ] **5.4.4** Build playback speed selector: 0.75x / 1x / 1.25x / 1.5x chips
|
||||
- [ ] **5.4.5** Implement verse highlight sync: highlight current verse based on audio position (timed segments per Surah)
|
||||
- [ ] **5.4.6** Implement auto-scroll: `ScrollController` scrolls to current verse during playback
|
||||
- [ ] **5.4.7** Configure background audio: `audio_service` handler, system media controls in notification tray
|
||||
- [ ] **5.4.8** Bundle short Surahs (Juz Amma: Surah 78–114) as local MP3 assets
|
||||
- [ ] **5.4.9** Implement streaming fallback for longer Surahs (mp3quran.net API)
|
||||
- [ ] **5.4.10** Build offline fallback state for streamed Surahs: "Connect to internet to play this Surah"
|
||||
- [ ] **5.4.11** Verify screen matches `stitch/quran_murattal_active_nav/screen.png`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Settings & System Polish
|
||||
> **Goal:** Settings screen complete, all toggles wired, notifications working end-to-end, dark mode seamless.
|
||||
> **Gate to Phase 7:** All settings persist, dark mode switches instantly, Adhan notification fires correctly, no hardcoded colors remain.
|
||||
> ⚠️ Depends on: Phase 4 + Phase 5 complete
|
||||
|
||||
### 6.1 Settings Screen
|
||||
- [ ] **6.1.1** Build Settings header: back + "Settings" title
|
||||
- [ ] **6.1.2** Build Profile section: avatar + name + email + edit button (inline editing)
|
||||
- [ ] **6.1.3** Build Notifications group: per-prayer Adhan toggles (5 prayers) using `IosToggle` widget
|
||||
- [ ] **6.1.4** Build Iqamah offset row: per-prayer minute offset picker (stepper, default 10 min)
|
||||
- [ ] **6.1.5** Build Daily Checklist Reminder row: time picker, default 9:00 AM
|
||||
- [ ] **6.1.6** Build Display group: Dark Mode 3-way control (Light/Dark/Auto), Font Size selector, Language selector
|
||||
- [ ] **6.1.7** Build About group: App Version, Privacy Policy (in-app WebView), Rate App link, Feedback
|
||||
- [ ] **6.1.8** Wire all toggles → write to `AppSettings` Hive box → trigger side effects (notification reschedule, theme change)
|
||||
- [ ] **6.1.9** Verify screen matches `stitch/settings_with_dark_mode_option/screen.png` and `stitch/settings_dark_mode/screen.png`
|
||||
|
||||
### 6.2 Dark Mode Polish
|
||||
- [ ] **6.2.1** Audit ALL screens in dark mode against `stitch/*_dark_mode/screen.png` — fix any color token mismatches
|
||||
- [ ] **6.2.2** Verify no hardcoded `Colors.white`, `Colors.black`, or raw hex strings remain in widget code (grep codebase)
|
||||
- [ ] **6.2.3** Verify `AnimatedTheme` transition is smooth (no flash/jank on toggle)
|
||||
- [ ] **6.2.4** Test system auto mode: app follows device dark mode setting correctly
|
||||
|
||||
### 6.3 Notification End-to-End
|
||||
- [ ] **6.3.1** Verify Adhan notifications fire at correct prayer times on Android emulator (API 34)
|
||||
- [ ] **6.3.2** Verify Adhan notifications fire correctly on iOS simulator (iOS 16+)
|
||||
- [ ] **6.3.3** Verify notifications are cancelled when prayer toggle is turned off in Settings
|
||||
- [ ] **6.3.4** Verify Iqamah notification fires `n` minutes after Adhan (configurable offset)
|
||||
- [ ] **6.3.5** Verify notifications reschedule correctly after location changes (new prayer times → cancel old → schedule new)
|
||||
- [ ] **6.3.6** Verify tapping notification → opens app on Dashboard screen
|
||||
|
||||
### 6.4 Accessibility Pass
|
||||
- [ ] **6.4.1** Audit all `IconButton`s and small tap targets: minimum 48×48dp (add `SizedBox` wrappers where needed)
|
||||
- [ ] **6.4.2** Add `Semantics(label: ...)` to all icon-only buttons across all screens
|
||||
- [ ] **6.4.3** Verify all Arabic text has explicit `textDirection: TextDirection.rtl`
|
||||
- [ ] **6.4.4** Verify contrast ratios for all text on primary bg (`#70df20` background with dark text = check)
|
||||
- [ ] **6.4.5** Test with TalkBack (Android) and VoiceOver (iOS) — all interactive elements must be reachable
|
||||
|
||||
### 6.5 Localization
|
||||
- [ ] **6.5.1** Set up `flutter_localizations` + `intl` in `pubspec.yaml`
|
||||
- [ ] **6.5.2** Create `lib/l10n/app_id.arb` (Indonesian) and `lib/l10n/app_en.arb` (English) with all UI strings
|
||||
- [ ] **6.5.3** Replace all hardcoded Indonesian strings in widgets with `AppLocalizations.of(context).*`
|
||||
- [ ] **6.5.4** Verify language switch in Settings changes all UI strings (no restart required)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — QA & Release Prep
|
||||
> **Goal:** App is production-ready. All screens verified, performance validated, store assets ready.
|
||||
> **Gate to ship:** Zero critical bugs, performance targets met, store listing complete.
|
||||
> ⚠️ Depends on: Phase 6 complete
|
||||
|
||||
### 7.1 Integration Testing
|
||||
- [ ] **7.1.1** Write integration test: full checklist flow (open app → check 5 items → verify progress = 50% → restart → verify persisted)
|
||||
- [ ] **7.1.2** Write integration test: prayer time accuracy for 5 major Indonesian cities (Jakarta, Surabaya, Medan, Makassar, Denpasar)
|
||||
- [ ] **7.1.3** Write integration test: dark mode toggle persists across app restart
|
||||
- [ ] **7.1.4** Write integration test: Dzikir counter increments and resets on next day
|
||||
- [ ] **7.1.5** Manual test: Quran reads all 114 Surahs without crash (test Surah 2 — largest Surah)
|
||||
- [ ] **7.1.6** Manual test: Murattal audio plays + background playback continues when screen locked
|
||||
|
||||
### 7.2 Performance Audit
|
||||
- [ ] **7.2.1** Profile cold start time on mid-range Android device — must be < 2 seconds
|
||||
- [ ] **7.2.2** Profile bottom nav tab switches — must be < 150ms
|
||||
- [ ] **7.2.3** Profile Quran Surah 2 scroll (286 verses) — must maintain 60fps
|
||||
- [ ] **7.2.4** Run `flutter analyze` — zero warnings, zero errors
|
||||
- [ ] **7.2.5** Run `flutter test` — all unit tests pass
|
||||
|
||||
### 7.3 App Assets & Store Prep
|
||||
- [ ] **7.3.1** Create app icon (1024×1024px): mosque/compass motif with `#70df20` primary color
|
||||
- [ ] **7.3.2** Apply app icon via `flutter_launcher_icons` package (all densities, adaptive icon for Android)
|
||||
- [ ] **7.3.3** Create splash screen via `flutter_native_splash` package (white/dark bg, centered logo)
|
||||
- [ ] **7.3.4** Set app name: "Jamshalat Diary" in `AndroidManifest.xml` and `Info.plist`
|
||||
- [ ] **7.3.5** Set bundle ID: `com.jamshalat.diary` on both platforms
|
||||
- [ ] **7.3.6** Configure release signing (Android keystore, iOS certificates) — document in private README
|
||||
- [ ] **7.3.7** Build release APK: `flutter build apk --release` — verify no build errors
|
||||
- [ ] **7.3.8** Build iOS release: `flutter build ipa --release` — verify no build errors
|
||||
- [ ] **7.3.9** Write Play Store listing: app description (Indonesian + English), screenshots (1 per key screen), tags
|
||||
- [ ] **7.3.10** Write App Store listing: same content, App Store Connect metadata
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracker
|
||||
|
||||
| Phase | Total Tasks | Done | Remaining | Status |
|
||||
|---|---|---|---|---|
|
||||
| Phase 1 — Foundation | 22 | 0 | 22 | Not started |
|
||||
| Phase 2 — Data Layer | 21 | 0 | 21 | Not started |
|
||||
| Phase 3 — Dashboard & Calendar | 18 | 0 | 18 | Not started |
|
||||
| Phase 4 — Worship Tracking | 28 | 0 | 28 | Not started |
|
||||
| Phase 5 — Islamic Tools | 27 | 0 | 27 | Not started |
|
||||
| Phase 6 — Settings & Polish | 22 | 0 | 22 | Not started |
|
||||
| Phase 7 — QA & Release | 20 | 0 | 20 | Not started |
|
||||
| **TOTAL** | **158** | **0** | **158** | 🔴 Not started |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `PRD.md` | Full product requirements — source of truth |
|
||||
| `stitch/*/screen.png` | Visual reference for each screen |
|
||||
| `stitch/*/code.html` | HTML implementation reference for each screen |
|
||||
| `TASKLIST.md` | This document — execution plan |
|
||||
|
||||
---
|
||||
|
||||
*TASKLIST v1.0 — Jamshalat Diary — March 2026*
|
||||
14
analysis_options.yaml
Normal file
14
analysis_options.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
prefer_final_fields: true
|
||||
prefer_final_locals: true
|
||||
avoid_print: true
|
||||
avoid_unnecessary_containers: true
|
||||
sized_box_for_whitespace: true
|
||||
use_key_in_widget_constructors: true
|
||||
prefer_single_quotes: true
|
||||
11
android/app/build.gradle
Normal file
11
android/app/build.gradle
Normal file
@@ -0,0 +1,11 @@
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.jamshalat.diary"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.ryanheise.audioservice.AudioServicePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin audio_service, com.ryanheise.audioservice.AudioServicePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.ryanheise.audio_session.AudioSessionPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.hemanthraj.fluttercompass.FlutterCompassPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_compass, com.hemanthraj.fluttercompass.FlutterCompassPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.hemanthraj.fluttercompass.FlutterCompassPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_compass_v2, com.hemanthraj.fluttercompass.FlutterCompassPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new ml.medyas.flutter_qiblah.FlutterQiblahPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_qiblah, ml.medyas.flutter_qiblah.FlutterQiblahPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.baseflow.geocoding.GeocodingPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin geocoding_android, com.baseflow.geocoding.GeocodingPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.ryanheise.just_audio.JustAudioPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
android/local.properties
Normal file
2
android/local.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
sdk.dir=/Users/dwindown/Library/Android/sdk
|
||||
flutter.sdk=/Users/dwindown/FlutterDev/flutter
|
||||
58
assets/dzikir/dzikir_pagi.json
Normal file
58
assets/dzikir/dzikir_pagi.json
Normal file
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"id": "pagi_01",
|
||||
"arabic": "أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ",
|
||||
"transliteration": "A'udzu billahi minasy syaithanir rajiim",
|
||||
"translation": "Aku berlindung kepada Allah dari godaan syaitan yang terkutuk",
|
||||
"target_count": 1,
|
||||
"source": "HR. Abu Dawud"
|
||||
},
|
||||
{
|
||||
"id": "pagi_02",
|
||||
"arabic": "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ",
|
||||
"transliteration": "Bismillahir rahmaanir rahiim",
|
||||
"translation": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang",
|
||||
"target_count": 1,
|
||||
"source": ""
|
||||
},
|
||||
{
|
||||
"id": "pagi_03",
|
||||
"arabic": "أَصْبَحْنَا وَأَصْبَحَ الْمُلْكُ لِلَّهِ، وَالْحَمْدُ لِلَّهِ",
|
||||
"transliteration": "Ashbahnaa wa ashbahal mulku lillah, walhamdu lillah",
|
||||
"translation": "Kami telah memasuki waktu pagi dan kerajaan hanya milik Allah, segala puji bagi Allah",
|
||||
"target_count": 1,
|
||||
"source": "HR. Muslim"
|
||||
},
|
||||
{
|
||||
"id": "pagi_04",
|
||||
"arabic": "اللَّهُمَّ بِكَ أَصْبَحْنَا وَبِكَ أَمْسَيْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ النُّشُورُ",
|
||||
"transliteration": "Allahumma bika ashbahnaa wa bika amsainaa wa bika nahyaa wa bika namuutu wa ilaikan nusyuur",
|
||||
"translation": "Ya Allah, dengan rahmat dan pertolongan-Mu kami memasuki waktu pagi, dan dengan rahmat-Mu kami memasuki waktu petang",
|
||||
"target_count": 1,
|
||||
"source": "HR. Tirmidzi"
|
||||
},
|
||||
{
|
||||
"id": "pagi_05",
|
||||
"arabic": "سُبْحَانَ اللَّهِ وَبِحَمْدِهِ",
|
||||
"transliteration": "Subhanallahi wa bihamdihi",
|
||||
"translation": "Maha Suci Allah dan dengan memuji-Nya",
|
||||
"target_count": 100,
|
||||
"source": "HR. Muslim"
|
||||
},
|
||||
{
|
||||
"id": "pagi_06",
|
||||
"arabic": "لاَ إِلَهَ إِلاَّ اللَّهُ وَحْدَهُ لاَ شَرِيكَ لَهُ",
|
||||
"transliteration": "Laa ilaaha illallahu wahdahu laa syariika lahu",
|
||||
"translation": "Tiada Tuhan selain Allah semata, tidak ada sekutu bagi-Nya",
|
||||
"target_count": 10,
|
||||
"source": "HR. Bukhari & Muslim"
|
||||
},
|
||||
{
|
||||
"id": "pagi_07",
|
||||
"arabic": "أَسْتَغْفِرُ اللَّهَ وَأَتُوبُ إِلَيْهِ",
|
||||
"transliteration": "Astaghfirullaha wa atuubu ilaihi",
|
||||
"translation": "Aku memohon ampun kepada Allah dan bertaubat kepada-Nya",
|
||||
"target_count": 100,
|
||||
"source": "HR. Bukhari & Muslim"
|
||||
}
|
||||
]
|
||||
58
assets/dzikir/dzikir_petang.json
Normal file
58
assets/dzikir/dzikir_petang.json
Normal file
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"id": "petang_01",
|
||||
"arabic": "أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ",
|
||||
"transliteration": "A'udzu billahi minasy syaithanir rajiim",
|
||||
"translation": "Aku berlindung kepada Allah dari godaan syaitan yang terkutuk",
|
||||
"target_count": 1,
|
||||
"source": "HR. Abu Dawud"
|
||||
},
|
||||
{
|
||||
"id": "petang_02",
|
||||
"arabic": "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ",
|
||||
"transliteration": "Bismillahir rahmaanir rahiim",
|
||||
"translation": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang",
|
||||
"target_count": 1,
|
||||
"source": ""
|
||||
},
|
||||
{
|
||||
"id": "petang_03",
|
||||
"arabic": "أَمْسَيْنَا وَأَمْسَى الْمُلْكُ لِلَّهِ، وَالْحَمْدُ لِلَّهِ",
|
||||
"transliteration": "Amsainaa wa amsal mulku lillah, walhamdu lillah",
|
||||
"translation": "Kami telah memasuki waktu petang dan kerajaan hanya milik Allah, segala puji bagi Allah",
|
||||
"target_count": 1,
|
||||
"source": "HR. Muslim"
|
||||
},
|
||||
{
|
||||
"id": "petang_04",
|
||||
"arabic": "اللَّهُمَّ بِكَ أَمْسَيْنَا وَبِكَ أَصْبَحْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ الْمَصِيرُ",
|
||||
"transliteration": "Allahumma bika amsainaa wa bika ashbahnaa wa bika nahyaa wa bika namuutu wa ilaikal mashiir",
|
||||
"translation": "Ya Allah, dengan rahmat-Mu kami memasuki waktu petang, dan dengan rahmat-Mu kami memasuki waktu pagi",
|
||||
"target_count": 1,
|
||||
"source": "HR. Tirmidzi"
|
||||
},
|
||||
{
|
||||
"id": "petang_05",
|
||||
"arabic": "سُبْحَانَ اللَّهِ وَبِحَمْدِهِ",
|
||||
"transliteration": "Subhanallahi wa bihamdihi",
|
||||
"translation": "Maha Suci Allah dan dengan memuji-Nya",
|
||||
"target_count": 100,
|
||||
"source": "HR. Muslim"
|
||||
},
|
||||
{
|
||||
"id": "petang_06",
|
||||
"arabic": "لاَ إِلَهَ إِلاَّ اللَّهُ وَحْدَهُ لاَ شَرِيكَ لَهُ",
|
||||
"transliteration": "Laa ilaaha illallahu wahdahu laa syariika lahu",
|
||||
"translation": "Tiada Tuhan selain Allah semata, tidak ada sekutu bagi-Nya",
|
||||
"target_count": 10,
|
||||
"source": "HR. Bukhari & Muslim"
|
||||
},
|
||||
{
|
||||
"id": "petang_07",
|
||||
"arabic": "أَسْتَغْفِرُ اللَّهَ وَأَتُوبُ إِلَيْهِ",
|
||||
"transliteration": "Astaghfirullaha wa atuubu ilaihi",
|
||||
"translation": "Aku memohon ampun kepada Allah dan bertaubat kepada-Nya",
|
||||
"target_count": 100,
|
||||
"source": "HR. Bukhari & Muslim"
|
||||
}
|
||||
]
|
||||
1
assets/fonts/Amiri-Bold.ttf
Normal file
1
assets/fonts/Amiri-Bold.ttf
Normal file
@@ -0,0 +1 @@
|
||||
Placeholder for Amiri-Bold.ttf
|
||||
BIN
assets/fonts/Amiri-Regular.ttf
Normal file
BIN
assets/fonts/Amiri-Regular.ttf
Normal file
Binary file not shown.
1469
assets/fonts/PlusJakartaSans-Bold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-Bold.ttf
Normal file
File diff suppressed because one or more lines are too long
1469
assets/fonts/PlusJakartaSans-ExtraBold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-ExtraBold.ttf
Normal file
File diff suppressed because one or more lines are too long
1469
assets/fonts/PlusJakartaSans-Regular.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
1469
assets/fonts/PlusJakartaSans-SemiBold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-SemiBold.ttf
Normal file
File diff suppressed because one or more lines are too long
100
assets/quran/quran_id.json
Normal file
100
assets/quran/quran_id.json
Normal file
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name_arabic": "الفاتحة",
|
||||
"name_latin": "Al-Fatihah",
|
||||
"verse_count": 7,
|
||||
"juz_start": 1,
|
||||
"revelation_type": "Meccan",
|
||||
"verses": [
|
||||
{
|
||||
"id": 1,
|
||||
"arabic": "بِسْمِ اللَّهِ الرَّحْمَـٰنِ الرَّحِيمِ",
|
||||
"transliteration": "Bismillaahir Rahmaanir Raheem",
|
||||
"translation_id": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"arabic": "الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ",
|
||||
"transliteration": "Alhamdu lillaahi Rabbil 'aalameen",
|
||||
"translation_id": "Segala puji bagi Allah, Tuhan seluruh alam"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"arabic": "الرَّحْمَـٰنِ الرَّحِيمِ",
|
||||
"transliteration": "Ar-Rahmaanir-Raheem",
|
||||
"translation_id": "Yang Maha Pengasih lagi Maha Penyayang"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"arabic": "مَالِكِ يَوْمِ الدِّينِ",
|
||||
"transliteration": "Maaliki Yawmid-Deen",
|
||||
"translation_id": "Pemilik hari pembalasan"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"arabic": "إِيَّاكَ نَعْبُدُ وَإِيَّاكَ نَسْتَعِينُ",
|
||||
"transliteration": "Iyyaaka na'budu wa lyyaaka nasta'een",
|
||||
"translation_id": "Hanya kepada Engkaulah kami menyembah dan hanya kepada Engkaulah kami mohon pertolongan"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"arabic": "اهْدِنَا الصِّرَاطَ الْمُسْتَقِيمَ",
|
||||
"transliteration": "Ihdinas-Siraatal-Mustaqeem",
|
||||
"translation_id": "Tunjukilah kami jalan yang lurus"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"arabic": "صِرَاطَ الَّذِينَ أَنْعَمْتَ عَلَيْهِمْ غَيْرِ الْمَغْضُوبِ عَلَيْهِمْ وَلَا الضَّالِّينَ",
|
||||
"transliteration": "Siraatal-lazeena an'amta 'alaihim ghayril-maghdoobi 'alaihim wa lad-daaalleen",
|
||||
"translation_id": "Yaitu jalan orang-orang yang telah Engkau beri nikmat kepadanya; bukan jalan mereka yang dimurkai dan bukan pula jalan mereka yang sesat"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 114,
|
||||
"name_arabic": "النَّاس",
|
||||
"name_latin": "An-Nas",
|
||||
"verse_count": 6,
|
||||
"juz_start": 30,
|
||||
"revelation_type": "Meccan",
|
||||
"verses": [
|
||||
{
|
||||
"id": 1,
|
||||
"arabic": "قُلْ أَعُوذُ بِرَبِّ النَّاسِ",
|
||||
"transliteration": "Qul a'uudzu birabbinnaas",
|
||||
"translation_id": "Katakanlah: Aku berlindung kepada Tuhannya manusia"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"arabic": "مَلِكِ النَّاسِ",
|
||||
"transliteration": "Malikinnaas",
|
||||
"translation_id": "Raja manusia"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"arabic": "إِلَـٰهِ النَّاسِ",
|
||||
"transliteration": "Ilaahinnaas",
|
||||
"translation_id": "Sembahan manusia"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"arabic": "مِن شَرِّ الْوَسْوَاسِ الْخَنَّاسِ",
|
||||
"transliteration": "Min syarril waswaasil khannaas",
|
||||
"translation_id": "Dari kejahatan bisikan syaitan yang biasa bersembunyi"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"arabic": "الَّذِي يُوَسْوِسُ فِي صُدُورِ النَّاسِ",
|
||||
"transliteration": "Allazii yuwaswisu fii suduurinnaas",
|
||||
"translation_id": "Yang membisikkan kejahatan ke dalam dada manusia"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"arabic": "مِنَ الْجِنَّةِ وَالنَّاسِ",
|
||||
"transliteration": "Minal jinnati wannaas",
|
||||
"translation_id": "Dari golongan jin dan manusia"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
14
ios/Flutter/Generated.xcconfig
Normal file
14
ios/Flutter/Generated.xcconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter
|
||||
FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib/main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
PACKAGE_CONFIG=.dart_tool/package_config.json
|
||||
32
ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
32
ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
import lldb
|
||||
|
||||
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
|
||||
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
|
||||
base = frame.register["x0"].GetValueAsAddress()
|
||||
page_len = frame.register["x1"].GetValueAsUnsigned()
|
||||
|
||||
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
|
||||
# first page to see if handled it correctly. This makes diagnosing
|
||||
# misconfiguration (e.g. missing breakpoint) easier.
|
||||
data = bytearray(page_len)
|
||||
data[0:8] = b'IHELPED!'
|
||||
|
||||
error = lldb.SBError()
|
||||
frame.GetThread().GetProcess().WriteMemory(base, data, error)
|
||||
if not error.Success():
|
||||
print(f'Failed to write into {base}[+{page_len}]', error)
|
||||
return
|
||||
|
||||
def __lldb_init_module(debugger: lldb.SBDebugger, _):
|
||||
target = debugger.GetDummyTarget()
|
||||
# Caveat: must use BreakpointCreateByRegEx here and not
|
||||
# BreakpointCreateByName. For some reasons callback function does not
|
||||
# get carried over from dummy target for the later.
|
||||
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
|
||||
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
|
||||
bp.SetAutoContinue(True)
|
||||
print("-- LLDB integration loaded --")
|
||||
5
ios/Flutter/ephemeral/flutter_lldbinit
Normal file
5
ios/Flutter/ephemeral/flutter_lldbinit
Normal file
@@ -0,0 +1,5 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||
13
ios/Flutter/flutter_export_environment.sh
Executable file
13
ios/Flutter/flutter_export_environment.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib/main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
|
||||
1
ios/Podfile
Normal file
1
ios/Podfile
Normal file
@@ -0,0 +1 @@
|
||||
platform :ios, '13.0'
|
||||
19
ios/Runner/GeneratedPluginRegistrant.h
Normal file
19
ios/Runner/GeneratedPluginRegistrant.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GeneratedPluginRegistrant_h
|
||||
#define GeneratedPluginRegistrant_h
|
||||
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface GeneratedPluginRegistrant : NSObject
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
#endif /* GeneratedPluginRegistrant_h */
|
||||
98
ios/Runner/GeneratedPluginRegistrant.m
Normal file
98
ios/Runner/GeneratedPluginRegistrant.m
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<audio_service/AudioServicePlugin.h>)
|
||||
#import <audio_service/AudioServicePlugin.h>
|
||||
#else
|
||||
@import audio_service;
|
||||
#endif
|
||||
|
||||
#if __has_include(<audio_session/AudioSessionPlugin.h>)
|
||||
#import <audio_session/AudioSessionPlugin.h>
|
||||
#else
|
||||
@import audio_session;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_compass/FlutterCompassPlugin.h>)
|
||||
#import <flutter_compass/FlutterCompassPlugin.h>
|
||||
#else
|
||||
@import flutter_compass;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_compass_v2/FlutterCompassPlugin.h>)
|
||||
#import <flutter_compass_v2/FlutterCompassPlugin.h>
|
||||
#else
|
||||
@import flutter_compass_v2;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
|
||||
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
|
||||
#else
|
||||
@import flutter_local_notifications;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_qiblah/FlutterQiblahPlugin.h>)
|
||||
#import <flutter_qiblah/FlutterQiblahPlugin.h>
|
||||
#else
|
||||
@import flutter_qiblah;
|
||||
#endif
|
||||
|
||||
#if __has_include(<geocoding_ios/GeocodingPlugin.h>)
|
||||
#import <geocoding_ios/GeocodingPlugin.h>
|
||||
#else
|
||||
@import geocoding_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
||||
#import <geolocator_apple/GeolocatorPlugin.h>
|
||||
#else
|
||||
@import geolocator_apple;
|
||||
#endif
|
||||
|
||||
#if __has_include(<just_audio/JustAudioPlugin.h>)
|
||||
#import <just_audio/JustAudioPlugin.h>
|
||||
#else
|
||||
@import just_audio;
|
||||
#endif
|
||||
|
||||
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
|
||||
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
|
||||
#else
|
||||
@import package_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
|
||||
#import <sqflite_darwin/SqflitePlugin.h>
|
||||
#else
|
||||
@import sqflite_darwin;
|
||||
#endif
|
||||
|
||||
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
|
||||
#import <url_launcher_ios/URLLauncherPlugin.h>
|
||||
#else
|
||||
@import url_launcher_ios;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[AudioServicePlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioServicePlugin"]];
|
||||
[AudioSessionPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioSessionPlugin"]];
|
||||
[FlutterCompassPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCompassPlugin"]];
|
||||
[FlutterCompassPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCompassPlugin"]];
|
||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||
[FlutterQiblahPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterQiblahPlugin"]];
|
||||
[GeocodingPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeocodingPlugin"]];
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
25
lib/app/app.dart
Normal file
25
lib/app/app.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/providers/theme_provider.dart';
|
||||
import 'router.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
|
||||
class App extends ConsumerWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Jamshalat Diary',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: themeMode,
|
||||
routerConfig: appRouter,
|
||||
);
|
||||
}
|
||||
}
|
||||
175
lib/app/router.dart
Normal file
175
lib/app/router.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/widgets/bottom_nav_bar.dart';
|
||||
import '../features/dashboard/presentation/dashboard_screen.dart';
|
||||
import '../features/imsakiyah/presentation/imsakiyah_screen.dart';
|
||||
import '../features/checklist/presentation/checklist_screen.dart';
|
||||
import '../features/laporan/presentation/laporan_screen.dart';
|
||||
import '../features/tools/presentation/tools_screen.dart';
|
||||
import '../features/dzikir/presentation/dzikir_screen.dart';
|
||||
import '../features/qibla/presentation/qibla_screen.dart';
|
||||
import '../features/quran/presentation/quran_screen.dart';
|
||||
import '../features/quran/presentation/quran_reading_screen.dart';
|
||||
import '../features/quran/presentation/quran_murattal_screen.dart';
|
||||
import '../features/quran/presentation/quran_bookmarks_screen.dart';
|
||||
import '../features/settings/presentation/settings_screen.dart';
|
||||
|
||||
/// Navigation key for the shell navigator (bottom-nav screens).
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// GoRouter configuration per PRD §5.2.
|
||||
final GoRouter appRouter = GoRouter(
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
// ── Shell route (bottom nav persists) ──
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) => _ScaffoldWithNav(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: DashboardScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'qibla',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QiblaScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/imsakiyah',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ImsakiyahScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/checklist',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ChecklistScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/laporan',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: LaporanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tools',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: ToolsScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dzikir',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const DzikirScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'quran',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'bookmarks',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QuranBookmarksScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':surahId',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
|
||||
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'murattal',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final surahId = state.pathParameters['surahId']!;
|
||||
final qariId = state.uri.queryParameters['qariId'];
|
||||
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
|
||||
return QuranMurattalScreen(
|
||||
surahId: surahId,
|
||||
initialQariId: qariId,
|
||||
autoPlay: autoplay,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'qibla',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const QiblaScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Settings (pushed, no bottom nav) ──
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// Scaffold wrapper that provides the persistent bottom nav bar.
|
||||
class _ScaffoldWithNav extends StatelessWidget {
|
||||
const _ScaffoldWithNav({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Maps route locations to bottom nav indices.
|
||||
int _currentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
if (location.startsWith('/imsakiyah')) return 1;
|
||||
if (location.startsWith('/checklist')) return 2;
|
||||
if (location.startsWith('/laporan')) return 3;
|
||||
if (location.startsWith('/tools')) return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context, int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/');
|
||||
break;
|
||||
case 1:
|
||||
context.go('/imsakiyah');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/checklist');
|
||||
break;
|
||||
case 3:
|
||||
context.go('/laporan');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/tools');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: AppBottomNavBar(
|
||||
currentIndex: _currentIndex(context),
|
||||
onTap: (i) => _onTap(context, i),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/app/theme/.gitkeep
Normal file
0
lib/app/theme/.gitkeep
Normal file
60
lib/app/theme/app_colors.dart
Normal file
60
lib/app/theme/app_colors.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// All color tokens from PRD §3.1 — light and dark values as static constants.
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Primary ──
|
||||
static const Color primary = Color(0xFF70DF20);
|
||||
static const Color onPrimary = Color(0xFF0A1A00);
|
||||
|
||||
// ── Background ──
|
||||
static const Color backgroundLight = Color(0xFFF7F8F6);
|
||||
static const Color backgroundDark = Color(0xFF182111);
|
||||
|
||||
// ── Surface ──
|
||||
static const Color surfaceLight = Color(0xFFFFFFFF);
|
||||
static const Color surfaceDark = Color(0xFF1E2A14);
|
||||
|
||||
// ── Sage (secondary text / section labels) ──
|
||||
static const Color sage = Color(0xFF728764);
|
||||
|
||||
// ── Cream (dividers, borders — light mode only) ──
|
||||
static const Color cream = Color(0xFFF2F4F0);
|
||||
|
||||
// ── Text ──
|
||||
static const Color textPrimaryLight = Color(0xFF1A2A0A);
|
||||
static const Color textPrimaryDark = Color(0xFFF2F4F0);
|
||||
static const Color textSecondaryLight = Color(0xFF64748B);
|
||||
static const Color textSecondaryDark = Color(0xFF94A3B8);
|
||||
|
||||
// ── Semantic ──
|
||||
static const Color errorLight = Color(0xFFEF4444);
|
||||
static const Color errorDark = Color(0xFFF87171);
|
||||
static const Color successLight = Color(0xFF22C55E);
|
||||
static const Color successDark = Color(0xFF4ADE80);
|
||||
|
||||
// ── Convenience helpers for theme building ──
|
||||
|
||||
static ColorScheme get lightColorScheme => ColorScheme.light(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
surface: surfaceLight,
|
||||
onSurface: textPrimaryLight,
|
||||
error: errorLight,
|
||||
onError: Colors.white,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
);
|
||||
|
||||
static ColorScheme get darkColorScheme => ColorScheme.dark(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
surface: surfaceDark,
|
||||
onSurface: textPrimaryDark,
|
||||
error: errorDark,
|
||||
onError: Colors.black,
|
||||
secondary: sage,
|
||||
onSecondary: Colors.white,
|
||||
);
|
||||
}
|
||||
70
lib/app/theme/app_text_styles.dart
Normal file
70
lib/app/theme/app_text_styles.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Typography definitions from PRD §3.2.
|
||||
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
static const String _fontFamily = 'PlusJakartaSans';
|
||||
|
||||
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
|
||||
static const TextTheme textTheme = TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
);
|
||||
|
||||
// ── Arabic text styles (Amiri — bundled font) ──
|
||||
|
||||
static const TextStyle arabicBody = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 2.0,
|
||||
);
|
||||
|
||||
static const TextStyle arabicLarge = TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 2.2,
|
||||
);
|
||||
}
|
||||
90
lib/app/theme/app_theme.dart
Normal file
90
lib/app/theme/app_theme.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_text_styles.dart';
|
||||
|
||||
/// ThemeData for light and dark modes, Material 3 enabled.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static ThemeData get light => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
scaffoldBackgroundColor: AppColors.backgroundLight,
|
||||
textTheme: AppTextStyles.textTheme.apply(
|
||||
bodyColor: AppColors.textPrimaryLight,
|
||||
displayColor: AppColors.textPrimaryLight,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: IconThemeData(color: AppColors.textPrimaryLight),
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.textPrimaryLight,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceLight,
|
||||
selectedItemColor: AppColors.primary,
|
||||
unselectedItemColor: AppColors.textSecondaryLight,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceLight,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.cream,
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.cream,
|
||||
);
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: AppColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: AppColors.backgroundDark,
|
||||
textTheme: AppTextStyles.textTheme.apply(
|
||||
bodyColor: AppColors.textPrimaryDark,
|
||||
displayColor: AppColors.textPrimaryDark,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: IconThemeData(color: AppColors.textPrimaryDark),
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.textPrimaryDark,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surfaceDark,
|
||||
selectedItemColor: AppColors.primary,
|
||||
unselectedItemColor: AppColors.textSecondaryDark,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surfaceDark,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.surfaceDark,
|
||||
);
|
||||
}
|
||||
4
lib/app/theme/theme.dart
Normal file
4
lib/app/theme/theme.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
// Barrel file for theme exports.
|
||||
export 'app_colors.dart';
|
||||
export 'app_text_styles.dart';
|
||||
export 'app_theme.dart';
|
||||
0
lib/core/providers/.gitkeep
Normal file
0
lib/core/providers/.gitkeep
Normal file
1
lib/core/providers/placeholder.dart
Normal file
1
lib/core/providers/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
12
lib/core/providers/theme_provider.dart
Normal file
12
lib/core/providers/theme_provider.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../data/local/hive_boxes.dart';
|
||||
import '../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Theme mode state provider.
|
||||
final themeProvider = StateProvider<ThemeMode>((ref) {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
return settings?.themeModeIndex == 1 ? ThemeMode.light : ThemeMode.dark;
|
||||
});
|
||||
54
lib/core/providers/tilawah_tracking_provider.dart
Normal file
54
lib/core/providers/tilawah_tracking_provider.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Models an active reading session for Tilawah
|
||||
class TilawahSession {
|
||||
final int startSurahId;
|
||||
final String startSurahName;
|
||||
final int startVerseId;
|
||||
|
||||
const TilawahSession({
|
||||
required this.startSurahId,
|
||||
required this.startSurahName,
|
||||
required this.startVerseId,
|
||||
});
|
||||
|
||||
TilawahSession copyWith({
|
||||
int? startSurahId,
|
||||
String? startSurahName,
|
||||
int? startVerseId,
|
||||
}) {
|
||||
return TilawahSession(
|
||||
startSurahId: startSurahId ?? this.startSurahId,
|
||||
startSurahName: startSurahName ?? this.startSurahName,
|
||||
startVerseId: startVerseId ?? this.startVerseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A state notifier to manage the global start state of a reading session.
|
||||
/// If state is null, no active tracking is occurring.
|
||||
class TilawahTrackingNotifier extends StateNotifier<TilawahSession?> {
|
||||
TilawahTrackingNotifier() : super(null);
|
||||
|
||||
/// Start a new tracking session
|
||||
void startTracking({
|
||||
required int surahId,
|
||||
required String surahName,
|
||||
required int verseId
|
||||
}) {
|
||||
state = TilawahSession(
|
||||
startSurahId: surahId,
|
||||
startSurahName: surahName,
|
||||
startVerseId: verseId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop tracking (after recording)
|
||||
void stopTracking() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
final tilawahTrackingProvider = StateNotifierProvider<TilawahTrackingNotifier, TilawahSession?>((ref) {
|
||||
return TilawahTrackingNotifier();
|
||||
});
|
||||
0
lib/core/utils/.gitkeep
Normal file
0
lib/core/utils/.gitkeep
Normal file
1
lib/core/utils/placeholder.dart
Normal file
1
lib/core/utils/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/core/widgets/.gitkeep
Normal file
0
lib/core/widgets/.gitkeep
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
/// 5-tab bottom navigation bar per PRD §5.1.
|
||||
/// Uses Material Symbols outlined (inactive) and filled (active).
|
||||
class AppBottomNavBar extends StatelessWidget {
|
||||
const AppBottomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: currentIndex,
|
||||
onTap: onTap,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.calendar_today_outlined),
|
||||
activeIcon: Icon(Icons.calendar_today),
|
||||
label: 'Jadwal',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.rule_outlined),
|
||||
activeIcon: Icon(Icons.rule),
|
||||
label: 'Ibadah',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
activeIcon: Icon(Icons.bar_chart),
|
||||
label: 'Laporan',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.auto_fix_high_outlined),
|
||||
activeIcon: Icon(Icons.auto_fix_high),
|
||||
label: 'Alat',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/core/widgets/ios_toggle.dart
Normal file
58
lib/core/widgets/ios_toggle.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
/// Custom iOS-style toggle switch (51×31dp) per PRD §6.9.
|
||||
/// Uses AnimatedContainer + GestureDetector for smooth animation.
|
||||
class IosToggle extends StatelessWidget {
|
||||
const IosToggle({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
static const double _width = 51.0;
|
||||
static const double _height = 31.0;
|
||||
static const double _thumbSize = 27.0;
|
||||
static const double _thumbPadding = 2.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: _width,
|
||||
height: _height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_height / 2),
|
||||
color: value ? AppColors.primary : AppColors.cream,
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: _thumbSize,
|
||||
height: _thumbSize,
|
||||
margin: const EdgeInsets.all(_thumbPadding),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/core/widgets/placeholder.dart
Normal file
1
lib/core/widgets/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
68
lib/core/widgets/prayer_time_card.dart
Normal file
68
lib/core/widgets/prayer_time_card.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
/// Reusable prayer time card widget for the horizontal scroll on Dashboard.
|
||||
/// Will be fully implemented in Phase 3.
|
||||
class PrayerTimeCard extends StatelessWidget {
|
||||
const PrayerTimeCard({
|
||||
super.key,
|
||||
required this.prayerName,
|
||||
required this.time,
|
||||
required this.icon,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
final String prayerName;
|
||||
final String time;
|
||||
final IconData icon;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: 112,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: theme.cardTheme.color,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? AppColors.primary
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
width: isActive ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: isActive ? AppColors.primary : AppColors.sage,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
prayerName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isActive ? AppColors.primary : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
time,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isActive
|
||||
? AppColors.primary
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/progress_bar.dart
Normal file
69
lib/core/widgets/progress_bar.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
/// Reusable linear progress bar with primary fill.
|
||||
/// Configurable height, borderRadius, and value (0.0–1.0).
|
||||
class AppProgressBar extends StatelessWidget {
|
||||
const AppProgressBar({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.height = 12.0,
|
||||
this.borderRadius,
|
||||
this.backgroundColor,
|
||||
this.fillColor,
|
||||
});
|
||||
|
||||
/// Progress value from 0.0 to 1.0.
|
||||
final double value;
|
||||
|
||||
/// Height of the bar. Default 12dp.
|
||||
final double height;
|
||||
|
||||
/// Border radius. Defaults to stadium (full).
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Background track color. Defaults to white/10 (dark) or primary/10 (light).
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Fill color. Defaults to AppColors.primary.
|
||||
final Color? fillColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final trackColor = backgroundColor ??
|
||||
(isDark
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: AppColors.primary.withValues(alpha: 0.1));
|
||||
final fill = fillColor ?? AppColors.primary;
|
||||
final radius = borderRadius ?? BorderRadius.circular(height / 2);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Track
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: trackColor,
|
||||
borderRadius: radius,
|
||||
),
|
||||
),
|
||||
// Fill
|
||||
FractionallySizedBox(
|
||||
widthFactor: value.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: radius,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/core/widgets/section_header.dart
Normal file
35
lib/core/widgets/section_header.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
/// Reusable uppercase section label (e.g. "NOTIFICATIONS", "DISPLAY").
|
||||
/// Uses sage color, tracking-wider, bold weight per PRD §3.2 labelSmall.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
const SectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: AppColors.sage,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
lib/data/local/adapters/.gitkeep
Normal file
0
lib/data/local/adapters/.gitkeep
Normal file
1
lib/data/local/adapters/placeholder.dart
Normal file
1
lib/data/local/adapters/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
119
lib/data/local/hive_boxes.dart
Normal file
119
lib/data/local/hive_boxes.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'models/app_settings.dart';
|
||||
import 'models/checklist_item.dart';
|
||||
import 'models/daily_worship_log.dart';
|
||||
import 'models/dzikir_counter.dart';
|
||||
import 'models/quran_bookmark.dart';
|
||||
import 'models/cached_prayer_times.dart';
|
||||
import 'models/shalat_log.dart';
|
||||
import 'models/tilawah_log.dart';
|
||||
import 'models/dzikir_log.dart';
|
||||
import 'models/puasa_log.dart';
|
||||
|
||||
/// Box name constants for Hive.
|
||||
class HiveBoxes {
|
||||
HiveBoxes._();
|
||||
|
||||
static const String settings = 'settings';
|
||||
static const String checklistItems = 'checklist_items';
|
||||
static const String worshipLogs = 'worship_logs';
|
||||
static const String dzikirCounters = 'dzikir_counters';
|
||||
static const String bookmarks = 'bookmarks';
|
||||
static const String cachedPrayerTimes = 'cached_prayer_times';
|
||||
}
|
||||
|
||||
/// Initialize Hive and open all boxes.
|
||||
Future<void> initHive() async {
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Register adapters
|
||||
Hive.registerAdapter(AppSettingsAdapter());
|
||||
Hive.registerAdapter(ChecklistItemAdapter());
|
||||
Hive.registerAdapter(DailyWorshipLogAdapter());
|
||||
Hive.registerAdapter(DzikirCounterAdapter());
|
||||
Hive.registerAdapter(QuranBookmarkAdapter());
|
||||
Hive.registerAdapter(CachedPrayerTimesAdapter());
|
||||
Hive.registerAdapter(ShalatLogAdapter());
|
||||
Hive.registerAdapter(TilawahLogAdapter());
|
||||
Hive.registerAdapter(DzikirLogAdapter());
|
||||
Hive.registerAdapter(PuasaLogAdapter());
|
||||
|
||||
// Open boxes
|
||||
try {
|
||||
await Hive.openBox<AppSettings>(HiveBoxes.settings);
|
||||
} catch (e) {
|
||||
debugPrint('Settings box corrupted, resetting: $e');
|
||||
if (Hive.isBoxOpen(HiveBoxes.settings)) {
|
||||
await Hive.box<AppSettings>(HiveBoxes.settings).close();
|
||||
}
|
||||
await Hive.deleteBoxFromDisk(HiveBoxes.settings);
|
||||
await Hive.openBox<AppSettings>(HiveBoxes.settings);
|
||||
}
|
||||
|
||||
await Hive.openBox<ChecklistItem>(HiveBoxes.checklistItems);
|
||||
final worshipBox = await Hive.openBox<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
|
||||
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
||||
|
||||
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
|
||||
final keysToDelete = [];
|
||||
for (final key in worshipBox.keys) {
|
||||
try {
|
||||
final log = worshipBox.get(key);
|
||||
if (log != null) {
|
||||
log.shalatLogs.values.toList(); // Force evaluation
|
||||
}
|
||||
} catch (_) {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (keysToDelete.isNotEmpty) {
|
||||
await worshipBox.deleteAll(keysToDelete);
|
||||
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds default settings and checklist items on first launch.
|
||||
Future<void> seedDefaults() async {
|
||||
// Seed AppSettings
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
if (settingsBox.isEmpty) {
|
||||
await settingsBox.put('default', AppSettings());
|
||||
}
|
||||
|
||||
// Seed default checklist items
|
||||
final checklistBox = Hive.box<ChecklistItem>(HiveBoxes.checklistItems);
|
||||
if (checklistBox.isEmpty) {
|
||||
final defaults = [
|
||||
ChecklistItem(
|
||||
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
|
||||
ChecklistItem(
|
||||
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
|
||||
ChecklistItem(
|
||||
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
|
||||
ChecklistItem(
|
||||
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
|
||||
ChecklistItem(
|
||||
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
|
||||
ChecklistItem(
|
||||
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
|
||||
subtitle: '1 Juz', sortOrder: 5),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 6),
|
||||
ChecklistItem(
|
||||
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
|
||||
subtitle: '1 session', sortOrder: 7),
|
||||
ChecklistItem(
|
||||
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
|
||||
ChecklistItem(
|
||||
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
|
||||
];
|
||||
for (final item in defaults) {
|
||||
await checklistBox.put(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
lib/data/local/models/app_settings.dart
Normal file
101
lib/data/local/models/app_settings.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'app_settings.g.dart';
|
||||
|
||||
/// User settings stored in Hive.
|
||||
@HiveType(typeId: 0)
|
||||
class AppSettings extends HiveObject {
|
||||
@HiveField(0)
|
||||
String userName;
|
||||
|
||||
@HiveField(1)
|
||||
String userEmail;
|
||||
|
||||
@HiveField(2)
|
||||
int themeModeIndex; // 0=system, 1=light, 2=dark
|
||||
|
||||
@HiveField(3)
|
||||
double arabicFontSize;
|
||||
|
||||
@HiveField(4)
|
||||
String uiLanguage; // 'id' or 'en'
|
||||
|
||||
@HiveField(5)
|
||||
Map<String, bool> adhanEnabled;
|
||||
|
||||
@HiveField(6)
|
||||
Map<String, int> iqamahOffset; // minutes
|
||||
|
||||
@HiveField(7)
|
||||
String? checklistReminderTime; // HH:mm format
|
||||
|
||||
@HiveField(8)
|
||||
double? lastLat;
|
||||
|
||||
@HiveField(9)
|
||||
double? lastLng;
|
||||
|
||||
@HiveField(10)
|
||||
String? lastCityName;
|
||||
|
||||
@HiveField(11)
|
||||
int rawatibLevel; // 0 = Off, 1 = Muakkad Only, 2 = Full
|
||||
|
||||
@HiveField(12)
|
||||
int tilawahTargetValue;
|
||||
|
||||
@HiveField(13)
|
||||
String tilawahTargetUnit; // 'Juz', 'Halaman', 'Ayat'
|
||||
|
||||
@HiveField(14)
|
||||
bool tilawahAutoSync;
|
||||
|
||||
@HiveField(15)
|
||||
bool trackDzikir;
|
||||
|
||||
@HiveField(16)
|
||||
bool trackPuasa;
|
||||
|
||||
@HiveField(17)
|
||||
bool showLatin;
|
||||
|
||||
@HiveField(18)
|
||||
bool showTerjemahan;
|
||||
|
||||
AppSettings({
|
||||
this.userName = 'User',
|
||||
this.userEmail = '',
|
||||
this.themeModeIndex = 0,
|
||||
this.arabicFontSize = 24.0,
|
||||
this.uiLanguage = 'id',
|
||||
Map<String, bool>? adhanEnabled,
|
||||
Map<String, int>? iqamahOffset,
|
||||
this.checklistReminderTime = '09:00',
|
||||
this.lastLat,
|
||||
this.lastLng,
|
||||
this.lastCityName,
|
||||
this.rawatibLevel = 1, // Default to Muakkad
|
||||
this.tilawahTargetValue = 1,
|
||||
this.tilawahTargetUnit = 'Juz',
|
||||
this.tilawahAutoSync = false,
|
||||
this.trackDzikir = true,
|
||||
this.trackPuasa = false,
|
||||
this.showLatin = true,
|
||||
this.showTerjemahan = true,
|
||||
}) : adhanEnabled = adhanEnabled ??
|
||||
{
|
||||
'fajr': true,
|
||||
'dhuhr': true,
|
||||
'asr': true,
|
||||
'maghrib': true,
|
||||
'isha': true,
|
||||
},
|
||||
iqamahOffset = iqamahOffset ??
|
||||
{
|
||||
'fajr': 15,
|
||||
'dhuhr': 10,
|
||||
'asr': 10,
|
||||
'maghrib': 5,
|
||||
'isha': 10,
|
||||
};
|
||||
}
|
||||
95
lib/data/local/models/app_settings.g.dart
Normal file
95
lib/data/local/models/app_settings.g.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
AppSettings read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AppSettings(
|
||||
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
|
||||
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
|
||||
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
|
||||
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
|
||||
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
|
||||
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
|
||||
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
|
||||
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
|
||||
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
|
||||
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
|
||||
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
|
||||
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
|
||||
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
|
||||
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
|
||||
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
|
||||
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
|
||||
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
|
||||
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
|
||||
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(19)
|
||||
..writeByte(0)
|
||||
..write(obj.userName)
|
||||
..writeByte(1)
|
||||
..write(obj.userEmail)
|
||||
..writeByte(2)
|
||||
..write(obj.themeModeIndex)
|
||||
..writeByte(3)
|
||||
..write(obj.arabicFontSize)
|
||||
..writeByte(4)
|
||||
..write(obj.uiLanguage)
|
||||
..writeByte(5)
|
||||
..write(obj.adhanEnabled)
|
||||
..writeByte(6)
|
||||
..write(obj.iqamahOffset)
|
||||
..writeByte(7)
|
||||
..write(obj.checklistReminderTime)
|
||||
..writeByte(8)
|
||||
..write(obj.lastLat)
|
||||
..writeByte(9)
|
||||
..write(obj.lastLng)
|
||||
..writeByte(10)
|
||||
..write(obj.lastCityName)
|
||||
..writeByte(11)
|
||||
..write(obj.rawatibLevel)
|
||||
..writeByte(12)
|
||||
..write(obj.tilawahTargetValue)
|
||||
..writeByte(13)
|
||||
..write(obj.tilawahTargetUnit)
|
||||
..writeByte(14)
|
||||
..write(obj.tilawahAutoSync)
|
||||
..writeByte(15)
|
||||
..write(obj.trackDzikir)
|
||||
..writeByte(16)
|
||||
..write(obj.trackPuasa)
|
||||
..writeByte(17)
|
||||
..write(obj.showLatin)
|
||||
..writeByte(18)
|
||||
..write(obj.showTerjemahan);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AppSettingsAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
50
lib/data/local/models/cached_prayer_times.dart
Normal file
50
lib/data/local/models/cached_prayer_times.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'cached_prayer_times.g.dart';
|
||||
|
||||
/// Cached prayer times for a specific location + date.
|
||||
@HiveType(typeId: 5)
|
||||
class CachedPrayerTimes extends HiveObject {
|
||||
@HiveField(0)
|
||||
String key; // 'lat_lng_yyyy-MM-dd'
|
||||
|
||||
@HiveField(1)
|
||||
double lat;
|
||||
|
||||
@HiveField(2)
|
||||
double lng;
|
||||
|
||||
@HiveField(3)
|
||||
String date;
|
||||
|
||||
@HiveField(4)
|
||||
DateTime fajr;
|
||||
|
||||
@HiveField(5)
|
||||
DateTime sunrise;
|
||||
|
||||
@HiveField(6)
|
||||
DateTime dhuhr;
|
||||
|
||||
@HiveField(7)
|
||||
DateTime asr;
|
||||
|
||||
@HiveField(8)
|
||||
DateTime maghrib;
|
||||
|
||||
@HiveField(9)
|
||||
DateTime isha;
|
||||
|
||||
CachedPrayerTimes({
|
||||
required this.key,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.date,
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
68
lib/data/local/models/cached_prayer_times.g.dart
Normal file
68
lib/data/local/models/cached_prayer_times.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cached_prayer_times.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CachedPrayerTimesAdapter extends TypeAdapter<CachedPrayerTimes> {
|
||||
@override
|
||||
final int typeId = 5;
|
||||
|
||||
@override
|
||||
CachedPrayerTimes read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return CachedPrayerTimes(
|
||||
key: fields[0] as String,
|
||||
lat: fields[1] as double,
|
||||
lng: fields[2] as double,
|
||||
date: fields[3] as String,
|
||||
fajr: fields[4] as DateTime,
|
||||
sunrise: fields[5] as DateTime,
|
||||
dhuhr: fields[6] as DateTime,
|
||||
asr: fields[7] as DateTime,
|
||||
maghrib: fields[8] as DateTime,
|
||||
isha: fields[9] as DateTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CachedPrayerTimes obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.key)
|
||||
..writeByte(1)
|
||||
..write(obj.lat)
|
||||
..writeByte(2)
|
||||
..write(obj.lng)
|
||||
..writeByte(3)
|
||||
..write(obj.date)
|
||||
..writeByte(4)
|
||||
..write(obj.fajr)
|
||||
..writeByte(5)
|
||||
..write(obj.sunrise)
|
||||
..writeByte(6)
|
||||
..write(obj.dhuhr)
|
||||
..writeByte(7)
|
||||
..write(obj.asr)
|
||||
..writeByte(8)
|
||||
..write(obj.maghrib)
|
||||
..writeByte(9)
|
||||
..write(obj.isha);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CachedPrayerTimesAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
34
lib/data/local/models/checklist_item.dart
Normal file
34
lib/data/local/models/checklist_item.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'checklist_item.g.dart';
|
||||
|
||||
/// A single checklist item definition (template, not daily state).
|
||||
@HiveType(typeId: 1)
|
||||
class ChecklistItem extends HiveObject {
|
||||
@HiveField(0)
|
||||
String id;
|
||||
|
||||
@HiveField(1)
|
||||
String title;
|
||||
|
||||
@HiveField(2)
|
||||
String category;
|
||||
|
||||
@HiveField(3)
|
||||
String? subtitle;
|
||||
|
||||
@HiveField(4)
|
||||
int sortOrder;
|
||||
|
||||
@HiveField(5)
|
||||
bool isCustom;
|
||||
|
||||
ChecklistItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.category,
|
||||
this.subtitle,
|
||||
required this.sortOrder,
|
||||
this.isCustom = false,
|
||||
});
|
||||
}
|
||||
56
lib/data/local/models/checklist_item.g.dart
Normal file
56
lib/data/local/models/checklist_item.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'checklist_item.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ChecklistItemAdapter extends TypeAdapter<ChecklistItem> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
ChecklistItem read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ChecklistItem(
|
||||
id: fields[0] as String,
|
||||
title: fields[1] as String,
|
||||
category: fields[2] as String,
|
||||
subtitle: fields[3] as String?,
|
||||
sortOrder: fields[4] as int,
|
||||
isCustom: fields[5] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ChecklistItem obj) {
|
||||
writer
|
||||
..writeByte(6)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.title)
|
||||
..writeByte(2)
|
||||
..write(obj.category)
|
||||
..writeByte(3)
|
||||
..write(obj.subtitle)
|
||||
..writeByte(4)
|
||||
..write(obj.sortOrder)
|
||||
..writeByte(5)
|
||||
..write(obj.isCustom);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ChecklistItemAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
86
lib/data/local/models/daily_worship_log.dart
Normal file
86
lib/data/local/models/daily_worship_log.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'shalat_log.dart';
|
||||
import 'tilawah_log.dart';
|
||||
import 'dzikir_log.dart';
|
||||
import 'puasa_log.dart';
|
||||
|
||||
part 'daily_worship_log.g.dart';
|
||||
|
||||
/// Daily worship completion log, keyed by date string 'yyyy-MM-dd'.
|
||||
@HiveType(typeId: 2)
|
||||
class DailyWorshipLog extends HiveObject {
|
||||
@HiveField(0)
|
||||
String date;
|
||||
|
||||
@HiveField(1)
|
||||
Map<String, ShalatLog> shalatLogs; // e.g., 'subuh' -> ShalatLog
|
||||
|
||||
@HiveField(5)
|
||||
TilawahLog? tilawahLog;
|
||||
|
||||
@HiveField(6)
|
||||
DzikirLog? dzikirLog;
|
||||
|
||||
@HiveField(7)
|
||||
PuasaLog? puasaLog;
|
||||
|
||||
@HiveField(2)
|
||||
int totalItems;
|
||||
|
||||
@HiveField(3)
|
||||
int completedCount;
|
||||
|
||||
@HiveField(4)
|
||||
double completionPercent;
|
||||
|
||||
DailyWorshipLog({
|
||||
required this.date,
|
||||
Map<String, ShalatLog>? shalatLogs,
|
||||
this.tilawahLog,
|
||||
this.dzikirLog,
|
||||
this.puasaLog,
|
||||
this.totalItems = 0,
|
||||
this.completedCount = 0,
|
||||
this.completionPercent = 0.0,
|
||||
}) : shalatLogs = shalatLogs ?? {};
|
||||
|
||||
/// Dynamically calculates the "Poin Ibadah" for this day.
|
||||
int get totalPoints {
|
||||
int points = 0;
|
||||
|
||||
// 1. Shalat Fardhu
|
||||
for (final sLog in shalatLogs.values) {
|
||||
if (sLog.completed) {
|
||||
if (sLog.location == 'Masjid') {
|
||||
points += 25;
|
||||
} else {
|
||||
points += 10;
|
||||
}
|
||||
}
|
||||
if (sLog.qabliyah == true) points += 5;
|
||||
if (sLog.badiyah == true) points += 5;
|
||||
}
|
||||
|
||||
// 2. Tilawah
|
||||
if (tilawahLog != null) {
|
||||
// 1 point per Ayat read
|
||||
points += tilawahLog!.rawAyatRead;
|
||||
|
||||
// Bonus 20 points for completing daily target
|
||||
if (tilawahLog!.isCompleted) {
|
||||
points += 20;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dzikir & Puasa
|
||||
if (dzikirLog != null) {
|
||||
if (dzikirLog!.pagi) points += 10;
|
||||
if (dzikirLog!.petang) points += 10;
|
||||
}
|
||||
if (puasaLog != null && puasaLog!.completed) {
|
||||
points += 30;
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
}
|
||||
70
lib/data/local/models/daily_worship_log.g.dart
Normal file
70
lib/data/local/models/daily_worship_log.g.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'daily_worship_log.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class DailyWorshipLogAdapter extends TypeAdapter<DailyWorshipLog> {
|
||||
@override
|
||||
final int typeId = 2;
|
||||
|
||||
@override
|
||||
DailyWorshipLog read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
Map<String, ShalatLog>? parsedShalatLogs;
|
||||
try {
|
||||
parsedShalatLogs = (fields[1] as Map?)?.cast<String, ShalatLog>();
|
||||
} catch (_) {
|
||||
// If casting fails (e.g. it was the old Map<String, bool>), ignore it.
|
||||
parsedShalatLogs = {};
|
||||
}
|
||||
|
||||
return DailyWorshipLog(
|
||||
date: fields[0] as String,
|
||||
shalatLogs: parsedShalatLogs,
|
||||
tilawahLog: fields[5] as TilawahLog?,
|
||||
dzikirLog: fields[6] as DzikirLog?,
|
||||
puasaLog: fields[7] as PuasaLog?,
|
||||
totalItems: fields[2] as int,
|
||||
completedCount: fields[3] as int,
|
||||
completionPercent: fields[4] as double,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, DailyWorshipLog obj) {
|
||||
writer
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.date)
|
||||
..writeByte(1)
|
||||
..write(obj.shalatLogs)
|
||||
..writeByte(5)
|
||||
..write(obj.tilawahLog)
|
||||
..writeByte(6)
|
||||
..write(obj.dzikirLog)
|
||||
..writeByte(7)
|
||||
..write(obj.puasaLog)
|
||||
..writeByte(2)
|
||||
..write(obj.totalItems)
|
||||
..writeByte(3)
|
||||
..write(obj.completedCount)
|
||||
..writeByte(4)
|
||||
..write(obj.completionPercent);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DailyWorshipLogAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
26
lib/data/local/models/dzikir_counter.dart
Normal file
26
lib/data/local/models/dzikir_counter.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'dzikir_counter.g.dart';
|
||||
|
||||
/// Counter for a single dzikir item on a specific date.
|
||||
@HiveType(typeId: 3)
|
||||
class DzikirCounter extends HiveObject {
|
||||
@HiveField(0)
|
||||
String dzikirId;
|
||||
|
||||
@HiveField(1)
|
||||
String date;
|
||||
|
||||
@HiveField(2)
|
||||
int count;
|
||||
|
||||
@HiveField(3)
|
||||
int target;
|
||||
|
||||
DzikirCounter({
|
||||
required this.dzikirId,
|
||||
required this.date,
|
||||
this.count = 0,
|
||||
required this.target,
|
||||
});
|
||||
}
|
||||
50
lib/data/local/models/dzikir_counter.g.dart
Normal file
50
lib/data/local/models/dzikir_counter.g.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'dzikir_counter.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class DzikirCounterAdapter extends TypeAdapter<DzikirCounter> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
DzikirCounter read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return DzikirCounter(
|
||||
dzikirId: fields[0] as String,
|
||||
date: fields[1] as String,
|
||||
count: fields[2] as int,
|
||||
target: fields[3] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, DzikirCounter obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.dzikirId)
|
||||
..writeByte(1)
|
||||
..write(obj.date)
|
||||
..writeByte(2)
|
||||
..write(obj.count)
|
||||
..writeByte(3)
|
||||
..write(obj.target);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DzikirCounterAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
17
lib/data/local/models/dzikir_log.dart
Normal file
17
lib/data/local/models/dzikir_log.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'dzikir_log.g.dart';
|
||||
|
||||
@HiveType(typeId: 9)
|
||||
class DzikirLog {
|
||||
@HiveField(0)
|
||||
bool pagi;
|
||||
|
||||
@HiveField(1)
|
||||
bool petang;
|
||||
|
||||
DzikirLog({
|
||||
this.pagi = false,
|
||||
this.petang = false,
|
||||
});
|
||||
}
|
||||
44
lib/data/local/models/dzikir_log.g.dart
Normal file
44
lib/data/local/models/dzikir_log.g.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'dzikir_log.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class DzikirLogAdapter extends TypeAdapter<DzikirLog> {
|
||||
@override
|
||||
final int typeId = 9;
|
||||
|
||||
@override
|
||||
DzikirLog read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return DzikirLog(
|
||||
pagi: fields[0] as bool,
|
||||
petang: fields[1] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, DzikirLog obj) {
|
||||
writer
|
||||
..writeByte(2)
|
||||
..writeByte(0)
|
||||
..write(obj.pagi)
|
||||
..writeByte(1)
|
||||
..write(obj.petang);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DzikirLogAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
17
lib/data/local/models/puasa_log.dart
Normal file
17
lib/data/local/models/puasa_log.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'puasa_log.g.dart';
|
||||
|
||||
@HiveType(typeId: 10)
|
||||
class PuasaLog {
|
||||
@HiveField(0)
|
||||
String? jenisPuasa; // 'Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', etc.
|
||||
|
||||
@HiveField(1)
|
||||
bool completed;
|
||||
|
||||
PuasaLog({
|
||||
this.jenisPuasa,
|
||||
this.completed = false,
|
||||
});
|
||||
}
|
||||
44
lib/data/local/models/puasa_log.g.dart
Normal file
44
lib/data/local/models/puasa_log.g.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'puasa_log.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class PuasaLogAdapter extends TypeAdapter<PuasaLog> {
|
||||
@override
|
||||
final int typeId = 10;
|
||||
|
||||
@override
|
||||
PuasaLog read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return PuasaLog(
|
||||
jenisPuasa: fields[0] as String?,
|
||||
completed: fields[1] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, PuasaLog obj) {
|
||||
writer
|
||||
..writeByte(2)
|
||||
..writeByte(0)
|
||||
..write(obj.jenisPuasa)
|
||||
..writeByte(1)
|
||||
..write(obj.completed);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PuasaLogAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
42
lib/data/local/models/quran_bookmark.dart
Normal file
42
lib/data/local/models/quran_bookmark.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'quran_bookmark.g.dart';
|
||||
|
||||
/// A bookmarked Quran verse.
|
||||
@HiveType(typeId: 4)
|
||||
class QuranBookmark extends HiveObject {
|
||||
@HiveField(0)
|
||||
int surahId;
|
||||
|
||||
@HiveField(1)
|
||||
int verseId;
|
||||
|
||||
@HiveField(2)
|
||||
String surahName;
|
||||
|
||||
@HiveField(3)
|
||||
String verseText; // Arabic snippet
|
||||
|
||||
@HiveField(4)
|
||||
DateTime savedAt;
|
||||
|
||||
@HiveField(5)
|
||||
bool isLastRead;
|
||||
|
||||
@HiveField(6)
|
||||
String? verseLatin;
|
||||
|
||||
@HiveField(7)
|
||||
String? verseTranslation;
|
||||
|
||||
QuranBookmark({
|
||||
required this.surahId,
|
||||
required this.verseId,
|
||||
required this.surahName,
|
||||
required this.verseText,
|
||||
required this.savedAt,
|
||||
this.isLastRead = false,
|
||||
this.verseLatin,
|
||||
this.verseTranslation,
|
||||
});
|
||||
}
|
||||
62
lib/data/local/models/quran_bookmark.g.dart
Normal file
62
lib/data/local/models/quran_bookmark.g.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'quran_bookmark.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class QuranBookmarkAdapter extends TypeAdapter<QuranBookmark> {
|
||||
@override
|
||||
final int typeId = 4;
|
||||
|
||||
@override
|
||||
QuranBookmark read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return QuranBookmark(
|
||||
surahId: fields[0] as int,
|
||||
verseId: fields[1] as int,
|
||||
surahName: fields[2] as String,
|
||||
verseText: fields[3] as String,
|
||||
savedAt: fields[4] as DateTime,
|
||||
isLastRead: fields[5] as bool? ?? false,
|
||||
verseLatin: fields[6] as String?,
|
||||
verseTranslation: fields[7] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, QuranBookmark obj) {
|
||||
writer
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.surahId)
|
||||
..writeByte(1)
|
||||
..write(obj.verseId)
|
||||
..writeByte(2)
|
||||
..write(obj.surahName)
|
||||
..writeByte(3)
|
||||
..write(obj.verseText)
|
||||
..writeByte(4)
|
||||
..write(obj.savedAt)
|
||||
..writeByte(5)
|
||||
..write(obj.isLastRead)
|
||||
..writeByte(6)
|
||||
..write(obj.verseLatin)
|
||||
..writeByte(7)
|
||||
..write(obj.verseTranslation);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is QuranBookmarkAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
25
lib/data/local/models/shalat_log.dart
Normal file
25
lib/data/local/models/shalat_log.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'shalat_log.g.dart';
|
||||
|
||||
@HiveType(typeId: 7)
|
||||
class ShalatLog {
|
||||
@HiveField(0)
|
||||
bool completed;
|
||||
|
||||
@HiveField(1)
|
||||
String? location; // 'Rumah' or 'Masjid'
|
||||
|
||||
@HiveField(2)
|
||||
bool? qabliyah;
|
||||
|
||||
@HiveField(3)
|
||||
bool? badiyah;
|
||||
|
||||
ShalatLog({
|
||||
this.completed = false,
|
||||
this.location,
|
||||
this.qabliyah,
|
||||
this.badiyah,
|
||||
});
|
||||
}
|
||||
50
lib/data/local/models/shalat_log.g.dart
Normal file
50
lib/data/local/models/shalat_log.g.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'shalat_log.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class ShalatLogAdapter extends TypeAdapter<ShalatLog> {
|
||||
@override
|
||||
final int typeId = 7;
|
||||
|
||||
@override
|
||||
ShalatLog read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return ShalatLog(
|
||||
completed: fields[0] as bool,
|
||||
location: fields[1] as String?,
|
||||
qabliyah: fields[2] as bool?,
|
||||
badiyah: fields[3] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ShalatLog obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.completed)
|
||||
..writeByte(1)
|
||||
..write(obj.location)
|
||||
..writeByte(2)
|
||||
..write(obj.qabliyah)
|
||||
..writeByte(3)
|
||||
..write(obj.badiyah);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ShalatLogAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
35
lib/data/local/models/tilawah_log.dart
Normal file
35
lib/data/local/models/tilawah_log.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'tilawah_log.g.dart';
|
||||
|
||||
@HiveType(typeId: 8)
|
||||
class TilawahLog {
|
||||
@HiveField(0)
|
||||
int targetValue;
|
||||
|
||||
@HiveField(1)
|
||||
String targetUnit; // 'Juz', 'Halaman', 'Ayat'
|
||||
|
||||
@HiveField(2)
|
||||
int currentProgress;
|
||||
|
||||
@HiveField(3)
|
||||
bool autoSync;
|
||||
|
||||
@HiveField(4)
|
||||
int rawAyatRead;
|
||||
|
||||
@HiveField(5)
|
||||
bool targetCompleted;
|
||||
|
||||
TilawahLog({
|
||||
this.targetValue = 1,
|
||||
this.targetUnit = 'Juz',
|
||||
this.currentProgress = 0,
|
||||
this.autoSync = false,
|
||||
this.rawAyatRead = 0,
|
||||
this.targetCompleted = false,
|
||||
});
|
||||
|
||||
bool get isCompleted => targetCompleted;
|
||||
}
|
||||
56
lib/data/local/models/tilawah_log.g.dart
Normal file
56
lib/data/local/models/tilawah_log.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tilawah_log.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class TilawahLogAdapter extends TypeAdapter<TilawahLog> {
|
||||
@override
|
||||
final int typeId = 8;
|
||||
|
||||
@override
|
||||
TilawahLog read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return TilawahLog(
|
||||
targetValue: fields[0] as int,
|
||||
targetUnit: fields[1] as String,
|
||||
currentProgress: fields[2] as int,
|
||||
autoSync: fields[3] as bool,
|
||||
rawAyatRead: fields[4] as int? ?? 0,
|
||||
targetCompleted: fields[5] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, TilawahLog obj) {
|
||||
writer
|
||||
..writeByte(6)
|
||||
..writeByte(0)
|
||||
..write(obj.targetValue)
|
||||
..writeByte(1)
|
||||
..write(obj.targetUnit)
|
||||
..writeByte(2)
|
||||
..write(obj.currentProgress)
|
||||
..writeByte(3)
|
||||
..write(obj.autoSync)
|
||||
..writeByte(4)
|
||||
..write(obj.rawAyatRead)
|
||||
..writeByte(5)
|
||||
..write(obj.targetCompleted);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TilawahLogAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
0
lib/data/services/.gitkeep
Normal file
0
lib/data/services/.gitkeep
Normal file
107
lib/data/services/dzikir_service.dart
Normal file
107
lib/data/services/dzikir_service.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/dzikir_counter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Represents a single dzikir item from the bundled JSON.
|
||||
class DzikirItem {
|
||||
final String id;
|
||||
final String arabic;
|
||||
final String transliteration;
|
||||
final String translation;
|
||||
final int targetCount;
|
||||
final String? source;
|
||||
|
||||
DzikirItem({
|
||||
required this.id,
|
||||
required this.arabic,
|
||||
required this.transliteration,
|
||||
required this.translation,
|
||||
required this.targetCount,
|
||||
this.source,
|
||||
});
|
||||
|
||||
factory DzikirItem.fromJson(Map<String, dynamic> json) {
|
||||
return DzikirItem(
|
||||
id: json['id'] as String,
|
||||
arabic: json['arabic'] as String? ?? '',
|
||||
transliteration: json['transliteration'] as String? ?? '',
|
||||
translation: json['translation'] as String? ?? '',
|
||||
targetCount: json['target_count'] as int? ?? 1,
|
||||
source: json['source'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of dzikir sessions.
|
||||
enum DzikirType { pagi, petang }
|
||||
|
||||
/// Service to load dzikir data and manage counters.
|
||||
class DzikirService {
|
||||
DzikirService._();
|
||||
static final DzikirService instance = DzikirService._();
|
||||
|
||||
final Map<DzikirType, List<DzikirItem>> _cache = {};
|
||||
|
||||
/// Load dzikir items from bundled JSON.
|
||||
Future<List<DzikirItem>> getDzikir(DzikirType type) async {
|
||||
if (_cache.containsKey(type)) return _cache[type]!;
|
||||
|
||||
final path = type == DzikirType.pagi
|
||||
? 'assets/dzikir/dzikir_pagi.json'
|
||||
: 'assets/dzikir/dzikir_petang.json';
|
||||
|
||||
try {
|
||||
final jsonString = await rootBundle.loadString(path);
|
||||
final List<dynamic> data = json.decode(jsonString);
|
||||
_cache[type] =
|
||||
data.map((d) => DzikirItem.fromJson(d as Map<String, dynamic>)).toList();
|
||||
} catch (_) {
|
||||
_cache[type] = [];
|
||||
}
|
||||
|
||||
return _cache[type]!;
|
||||
}
|
||||
|
||||
/// Get counters for a specific date from Hive.
|
||||
Map<String, int> getCountersForDate(String date) {
|
||||
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
final result = <String, int>{};
|
||||
|
||||
for (final key in box.keys) {
|
||||
final counter = box.get(key);
|
||||
if (counter != null && counter.date == date) {
|
||||
result[counter.dzikirId] = counter.count;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Increment a dzikir counter for a specific ID on a specific date.
|
||||
Future<void> increment(String dzikirId, String date, int target) async {
|
||||
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
final key = '${dzikirId}_$date';
|
||||
|
||||
final existing = box.get(key);
|
||||
if (existing != null) {
|
||||
existing.count = (existing.count + 1).clamp(0, target);
|
||||
await existing.save();
|
||||
} else {
|
||||
await box.put(
|
||||
key,
|
||||
DzikirCounter(
|
||||
dzikirId: dzikirId,
|
||||
date: date,
|
||||
count: 1,
|
||||
target: target,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get today's date string.
|
||||
String get todayKey => DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
}
|
||||
108
lib/data/services/equran_service.dart
Normal file
108
lib/data/services/equran_service.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service for EQuran.id v2 API.
|
||||
/// Provides complete Quran data: Arabic, Indonesian translation,
|
||||
/// tafsir, and audio from 6 qari.
|
||||
class EQuranService {
|
||||
static const String _baseUrl = 'https://equran.id/api/v2';
|
||||
static final EQuranService instance = EQuranService._();
|
||||
EQuranService._();
|
||||
|
||||
// In-memory cache
|
||||
List<Map<String, dynamic>>? _surahListCache;
|
||||
|
||||
/// Get list of all 114 surahs.
|
||||
Future<List<Map<String, dynamic>>> getAllSurahs() async {
|
||||
if (_surahListCache != null) return _surahListCache!;
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$_baseUrl/surat'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['code'] == 200) {
|
||||
_surahListCache =
|
||||
List<Map<String, dynamic>>.from(data['data']);
|
||||
return _surahListCache!;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get full surah with all ayat, audio, etc.
|
||||
/// Returns the full surah data object.
|
||||
Future<Map<String, dynamic>?> getSurah(int number) async {
|
||||
try {
|
||||
final response =
|
||||
await http.get(Uri.parse('$_baseUrl/surat/$number'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['code'] == 200) {
|
||||
return Map<String, dynamic>.from(data['data']);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get tafsir for a surah.
|
||||
Future<Map<String, dynamic>?> getTafsir(int number) async {
|
||||
try {
|
||||
final response =
|
||||
await http.get(Uri.parse('$_baseUrl/tafsir/$number'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['code'] == 200) {
|
||||
return Map<String, dynamic>.from(data['data']);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get deterministic daily ayat from API
|
||||
Future<Map<String, dynamic>?> getDailyAyat() async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final dayOfYear = int.parse(now.difference(DateTime(now.year, 1, 1)).inDays.toString());
|
||||
|
||||
// Pick surah 1-114
|
||||
int surahId = (dayOfYear % 114) + 1;
|
||||
|
||||
final surahData = await getSurah(surahId);
|
||||
if (surahData != null && surahData['ayat'] != null) {
|
||||
int totalAyat = surahData['jumlahAyat'] ?? 1;
|
||||
int ayatIndex = dayOfYear % totalAyat;
|
||||
|
||||
final targetAyat = surahData['ayat'][ayatIndex];
|
||||
|
||||
return {
|
||||
'surahName': surahData['namaLatin'],
|
||||
'nomorSurah': surahId,
|
||||
'nomorAyat': targetAyat['nomorAyat'],
|
||||
'teksArab': targetAyat['teksArab'],
|
||||
'teksIndonesia': targetAyat['teksIndonesia'],
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Available qari names mapped to audio key index.
|
||||
static const Map<String, String> qariNames = {
|
||||
'01': 'Abdullah Al-Juhany',
|
||||
'02': 'Abdul Muhsin Al-Qasim',
|
||||
'03': 'Abdurrahman As-Sudais',
|
||||
'04': 'Ibrahim Al-Dossari',
|
||||
'05': 'Misyari Rasyid Al-Afasi',
|
||||
'06': 'Yasser Al-Dosari',
|
||||
};
|
||||
}
|
||||
86
lib/data/services/location_service.dart
Normal file
86
lib/data/services/location_service.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:geocoding/geocoding.dart' as geocoding;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/app_settings.dart';
|
||||
|
||||
/// Location service with GPS + fallback to last known location.
|
||||
class LocationService {
|
||||
LocationService._();
|
||||
static final LocationService instance = LocationService._();
|
||||
|
||||
/// Request permission and get current GPS location.
|
||||
Future<Position?> getCurrentLocation() async {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return null;
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) return null;
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) return null;
|
||||
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.medium,
|
||||
timeLimit: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
|
||||
// Save to settings for fallback
|
||||
await _saveLastKnown(position.latitude, position.longitude);
|
||||
return position;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get last known location from Hive settings.
|
||||
({double lat, double lng, String? cityName})? getLastKnownLocation() {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default');
|
||||
if (settings?.lastLat != null && settings?.lastLng != null) {
|
||||
return (
|
||||
lat: settings!.lastLat!,
|
||||
lng: settings.lastLng!,
|
||||
cityName: settings.lastCityName,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Reverse geocode to get city name from coordinates.
|
||||
Future<String> getCityName(double lat, double lng) async {
|
||||
try {
|
||||
final placemarks = await geocoding.placemarkFromCoordinates(lat, lng);
|
||||
if (placemarks.isNotEmpty) {
|
||||
final place = placemarks.first;
|
||||
final city = place.locality ?? place.subAdministrativeArea ?? 'Unknown';
|
||||
final country = place.country ?? '';
|
||||
return '$city, $country';
|
||||
}
|
||||
} catch (_) {
|
||||
// Geocoding may fail offline — return coords
|
||||
}
|
||||
return '${lat.toStringAsFixed(2)}, ${lng.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
/// Save last known position to Hive.
|
||||
Future<void> _saveLastKnown(double lat, double lng) async {
|
||||
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = settingsBox.get('default');
|
||||
if (settings != null) {
|
||||
settings.lastLat = lat;
|
||||
settings.lastLng = lng;
|
||||
try {
|
||||
settings.lastCityName = await getCityName(lat, lng);
|
||||
} catch (_) {
|
||||
// Ignore geocoding errors
|
||||
}
|
||||
await settings.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
108
lib/data/services/myquran_sholat_service.dart
Normal file
108
lib/data/services/myquran_sholat_service.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service for myQuran.com v3 Sholat API.
|
||||
/// Provides Kemenag-accurate prayer times for Indonesian cities.
|
||||
class MyQuranSholatService {
|
||||
static const String _baseUrl = 'https://api.myquran.com/v3/sholat';
|
||||
static final MyQuranSholatService instance = MyQuranSholatService._();
|
||||
MyQuranSholatService._();
|
||||
|
||||
/// Search for a city/kabupaten by name.
|
||||
/// Returns list of {id, lokasi}.
|
||||
Future<List<Map<String, dynamic>>> searchCity(String query) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/kota/cari/$query'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == true) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get prayer times for a specific city and date.
|
||||
/// [cityId] = myQuran city ID (hash string)
|
||||
/// [date] = 'yyyy-MM-dd' format
|
||||
/// Returns map: {tanggal, imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
Future<Map<String, String>?> getDailySchedule(
|
||||
String cityId, String date) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/jadwal/$cityId/$date'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == true) {
|
||||
final jadwal = data['data']['jadwal'][date];
|
||||
if (jadwal != null) {
|
||||
return Map<String, String>.from(
|
||||
jadwal.map((k, v) => MapEntry(k.toString(), v.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get monthly prayer schedule.
|
||||
/// [month] = 'yyyy-MM' format
|
||||
/// Returns map of date → jadwal.
|
||||
Future<Map<String, Map<String, String>>> getMonthlySchedule(
|
||||
String cityId, String month) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/jadwal/$cityId/$month'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == true) {
|
||||
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
|
||||
final result = <String, Map<String, String>>{};
|
||||
for (final entry in jadwalMap.entries) {
|
||||
result[entry.key] = Map<String, String>.from(
|
||||
(entry.value as Map).map(
|
||||
(k, v) => MapEntry(k.toString(), v.toString())),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Get city info (kabko, prov) from a jadwal response.
|
||||
Future<Map<String, String>?> getCityInfo(String cityId) async {
|
||||
final today =
|
||||
DateTime.now().toIso8601String().substring(0, 10);
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/jadwal/$cityId/$today'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == true) {
|
||||
return {
|
||||
'kabko': data['data']['kabko']?.toString() ?? '',
|
||||
'prov': data['data']['prov']?.toString() ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fallback
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/notification_service.dart
Normal file
98
lib/data/services/notification_service.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
/// Notification service for Adhan and Iqamah notifications.
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
static final NotificationService instance = NotificationService._();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
/// Initialize notification channels.
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const darwinSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: darwinSettings,
|
||||
macOS: darwinSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(settings);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Schedule an Adhan notification at a specific time.
|
||||
Future<void> scheduleAdhan({
|
||||
required int id,
|
||||
required String prayerName,
|
||||
required DateTime time,
|
||||
}) async {
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
'Adhan - $prayerName',
|
||||
'It\'s time for $prayerName prayer',
|
||||
tz.TZDateTime.from(time, tz.local),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'adhan_channel',
|
||||
'Adhan Notifications',
|
||||
channelDescription: 'Prayer time adhan notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedule an Iqamah reminder notification.
|
||||
Future<void> scheduleIqamah({
|
||||
required int id,
|
||||
required String prayerName,
|
||||
required DateTime adhanTime,
|
||||
required int offsetMinutes,
|
||||
}) async {
|
||||
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
|
||||
await _plugin.zonedSchedule(
|
||||
id + 100, // Offset IDs for iqamah
|
||||
'Iqamah - $prayerName',
|
||||
'Iqamah for $prayerName in $offsetMinutes minutes',
|
||||
tz.TZDateTime.from(iqamahTime, tz.local),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'iqamah_channel',
|
||||
'Iqamah Reminders',
|
||||
channelDescription: 'Iqamah reminder notifications',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel all pending notifications.
|
||||
Future<void> cancelAll() async {
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
}
|
||||
1
lib/data/services/placeholder.dart
Normal file
1
lib/data/services/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
126
lib/data/services/prayer_service.dart
Normal file
126
lib/data/services/prayer_service.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:adhan/adhan.dart' as adhan;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../local/hive_boxes.dart';
|
||||
import '../local/models/cached_prayer_times.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Result object for prayer times.
|
||||
class PrayerTimesResult {
|
||||
final DateTime fajr;
|
||||
final DateTime sunrise;
|
||||
final DateTime dhuhr;
|
||||
final DateTime asr;
|
||||
final DateTime maghrib;
|
||||
final DateTime isha;
|
||||
|
||||
PrayerTimesResult({
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
|
||||
/// Prayer time calculation service using the adhan package.
|
||||
class PrayerService {
|
||||
PrayerService._();
|
||||
static final PrayerService instance = PrayerService._();
|
||||
|
||||
/// Calculate prayer times for a given location and date.
|
||||
/// Uses cache if available; writes to cache after calculation.
|
||||
PrayerTimesResult getPrayerTimes(double lat, double lng, DateTime date) {
|
||||
final dateKey = DateFormat('yyyy-MM-dd').format(date);
|
||||
final cacheKey = '${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}_$dateKey';
|
||||
|
||||
// Check cache
|
||||
final cacheBox = Hive.box<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
|
||||
final cached = cacheBox.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return PrayerTimesResult(
|
||||
fajr: cached.fajr,
|
||||
sunrise: cached.sunrise,
|
||||
dhuhr: cached.dhuhr,
|
||||
asr: cached.asr,
|
||||
maghrib: cached.maghrib,
|
||||
isha: cached.isha,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate using adhan package
|
||||
final coordinates = adhan.Coordinates(lat, lng);
|
||||
final dateComponents = adhan.DateComponents(date.year, date.month, date.day);
|
||||
final params = adhan.CalculationMethod.muslim_world_league.getParameters();
|
||||
params.madhab = adhan.Madhab.shafi;
|
||||
|
||||
final prayerTimes = adhan.PrayerTimes(coordinates, dateComponents, params);
|
||||
|
||||
final result = PrayerTimesResult(
|
||||
fajr: prayerTimes.fajr!,
|
||||
sunrise: prayerTimes.sunrise!,
|
||||
dhuhr: prayerTimes.dhuhr!,
|
||||
asr: prayerTimes.asr!,
|
||||
maghrib: prayerTimes.maghrib!,
|
||||
isha: prayerTimes.isha!,
|
||||
);
|
||||
|
||||
// Cache result
|
||||
cacheBox.put(
|
||||
cacheKey,
|
||||
CachedPrayerTimes(
|
||||
key: cacheKey,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
date: dateKey,
|
||||
fajr: result.fajr,
|
||||
sunrise: result.sunrise,
|
||||
dhuhr: result.dhuhr,
|
||||
asr: result.asr,
|
||||
maghrib: result.maghrib,
|
||||
isha: result.isha,
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get the next prayer name and time from now.
|
||||
MapEntry<String, DateTime>? getNextPrayer(PrayerTimesResult times) {
|
||||
final now = DateTime.now();
|
||||
final entries = {
|
||||
'Fajr': times.fajr,
|
||||
'Dhuhr': times.dhuhr,
|
||||
'Asr': times.asr,
|
||||
'Maghrib': times.maghrib,
|
||||
'Isha': times.isha,
|
||||
};
|
||||
|
||||
for (final entry in entries.entries) {
|
||||
if (entry.value.isAfter(now)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null; // All prayers passed for today
|
||||
}
|
||||
|
||||
/// Get the current active prayer (the last prayer whose time has passed).
|
||||
String? getCurrentPrayer(PrayerTimesResult times) {
|
||||
final now = DateTime.now();
|
||||
String? current;
|
||||
|
||||
if (now.isAfter(times.isha)) {
|
||||
current = 'Isha';
|
||||
} else if (now.isAfter(times.maghrib)) {
|
||||
current = 'Maghrib';
|
||||
} else if (now.isAfter(times.asr)) {
|
||||
current = 'Asr';
|
||||
} else if (now.isAfter(times.dhuhr)) {
|
||||
current = 'Dhuhr';
|
||||
} else if (now.isAfter(times.fajr)) {
|
||||
current = 'Fajr';
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
98
lib/data/services/quran_service.dart
Normal file
98
lib/data/services/quran_service.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Represents a single Surah with its verses.
|
||||
class Surah {
|
||||
final int id;
|
||||
final String nameArabic;
|
||||
final String nameLatin;
|
||||
final int verseCount;
|
||||
final int juzStart;
|
||||
final String revelationType;
|
||||
final List<Verse> verses;
|
||||
|
||||
Surah({
|
||||
required this.id,
|
||||
required this.nameArabic,
|
||||
required this.nameLatin,
|
||||
required this.verseCount,
|
||||
this.juzStart = 1,
|
||||
this.revelationType = 'Meccan',
|
||||
this.verses = const [],
|
||||
});
|
||||
|
||||
factory Surah.fromJson(Map<String, dynamic> json) {
|
||||
return Surah(
|
||||
id: json['id'] as int,
|
||||
nameArabic: json['name_arabic'] as String? ?? '',
|
||||
nameLatin: json['name_latin'] as String? ?? '',
|
||||
verseCount: json['verse_count'] as int? ?? 0,
|
||||
juzStart: json['juz_start'] as int? ?? 1,
|
||||
revelationType: json['revelation_type'] as String? ?? 'Meccan',
|
||||
verses: (json['verses'] as List<dynamic>?)
|
||||
?.map((v) => Verse.fromJson(v as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Quran verse.
|
||||
class Verse {
|
||||
final int id;
|
||||
final String arabic;
|
||||
final String? transliteration;
|
||||
final String translationId;
|
||||
|
||||
Verse({
|
||||
required this.id,
|
||||
required this.arabic,
|
||||
this.transliteration,
|
||||
required this.translationId,
|
||||
});
|
||||
|
||||
factory Verse.fromJson(Map<String, dynamic> json) {
|
||||
return Verse(
|
||||
id: json['id'] as int,
|
||||
arabic: json['arabic'] as String? ?? '',
|
||||
transliteration: json['transliteration'] as String?,
|
||||
translationId: json['translation_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service to load Quran data from bundled JSON asset.
|
||||
class QuranService {
|
||||
QuranService._();
|
||||
static final QuranService instance = QuranService._();
|
||||
|
||||
List<Surah>? _cachedSurahs;
|
||||
|
||||
/// Load all 114 Surahs from local JSON. Cached in memory after first load.
|
||||
Future<List<Surah>> getAllSurahs() async {
|
||||
if (_cachedSurahs != null) return _cachedSurahs!;
|
||||
|
||||
try {
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/quran/quran_id.json');
|
||||
final List<dynamic> data = json.decode(jsonString);
|
||||
_cachedSurahs = data
|
||||
.map((s) => Surah.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
_cachedSurahs = [];
|
||||
}
|
||||
|
||||
return _cachedSurahs!;
|
||||
}
|
||||
|
||||
/// Get a single Surah by ID.
|
||||
Future<Surah?> getSurah(int id) async {
|
||||
final surahs = await getAllSurahs();
|
||||
try {
|
||||
return surahs.firstWhere((s) => s.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
83
lib/data/services/unsplash_service.dart
Normal file
83
lib/data/services/unsplash_service.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
/// Service for fetching Islamic-themed photos from Unsplash.
|
||||
/// Implements aggressive caching to minimize API usage (1 request/day).
|
||||
class UnsplashService {
|
||||
static const String _baseUrl = 'https://api.unsplash.com';
|
||||
static const String _cacheBoxName = 'unsplash_cache';
|
||||
static const String _cacheKey = 'cached_photo';
|
||||
static const String _cacheTimestampKey = 'cached_timestamp';
|
||||
static const Duration _cacheTTL = Duration(hours: 24);
|
||||
|
||||
static final UnsplashService instance = UnsplashService._();
|
||||
UnsplashService._();
|
||||
|
||||
// In-memory cache for the current session
|
||||
Map<String, String>? _memoryCache;
|
||||
|
||||
/// Get a cached or fresh Islamic photo.
|
||||
/// Returns a map with keys: 'imageUrl', 'photographerName', 'photographerUrl', 'unsplashUrl'
|
||||
Future<Map<String, String>?> getIslamicPhoto() async {
|
||||
// 1. Check memory cache
|
||||
if (_memoryCache != null) return _memoryCache;
|
||||
|
||||
// 2. Check Hive cache
|
||||
final box = await Hive.openBox(_cacheBoxName);
|
||||
final cachedData = box.get(_cacheKey);
|
||||
final cachedTimestamp = box.get(_cacheTimestampKey);
|
||||
|
||||
if (cachedData != null && cachedTimestamp != null) {
|
||||
final cachedTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp);
|
||||
if (DateTime.now().difference(cachedTime) < _cacheTTL) {
|
||||
_memoryCache = Map<String, String>.from(json.decode(cachedData));
|
||||
return _memoryCache;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch from API
|
||||
final photo = await _fetchFromApi();
|
||||
if (photo != null) {
|
||||
// Cache in Hive
|
||||
await box.put(_cacheKey, json.encode(photo));
|
||||
await box.put(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_memoryCache = photo;
|
||||
}
|
||||
|
||||
return photo;
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> _fetchFromApi() async {
|
||||
final accessKey = dotenv.env['UNSPLASH_ACCESS_KEY'];
|
||||
if (accessKey == null || accessKey.isEmpty || accessKey == 'YOUR_ACCESS_KEY_HERE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final queries = ['masjid', 'kaabah', 'mosque', 'islamic architecture'];
|
||||
// Rotate query based on the day of year for variety
|
||||
final dayOfYear = DateTime.now().difference(DateTime(DateTime.now().year, 1, 1)).inDays;
|
||||
final query = queries[dayOfYear % queries.length];
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/photos/random?query=$query&orientation=portrait&content_filter=high'),
|
||||
headers: {'Authorization': 'Client-ID $accessKey'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return {
|
||||
'imageUrl': data['urls']?['regular'] ?? '',
|
||||
'photographerName': data['user']?['name'] ?? 'Unknown',
|
||||
'photographerUrl': data['user']?['links']?['html'] ?? '',
|
||||
'unsplashUrl': data['links']?['html'] ?? '',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fallback — show the equalizer without background
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
0
lib/features/checklist/presentation/.gitkeep
Normal file
0
lib/features/checklist/presentation/.gitkeep
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal file
648
lib/features/checklist/presentation/checklist_screen.dart
Normal file
@@ -0,0 +1,648 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/local/models/shalat_log.dart';
|
||||
import '../../../data/local/models/tilawah_log.dart';
|
||||
import '../../../data/local/models/dzikir_log.dart';
|
||||
import '../../../data/local/models/puasa_log.dart';
|
||||
|
||||
class ChecklistScreen extends ConsumerStatefulWidget {
|
||||
const ChecklistScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChecklistScreen> createState() => _ChecklistScreenState();
|
||||
}
|
||||
|
||||
class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
|
||||
late String _todayKey;
|
||||
late Box<DailyWorshipLog> _logBox;
|
||||
late Box<AppSettings> _settingsBox;
|
||||
late AppSettings _settings;
|
||||
|
||||
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
_settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = _settingsBox.get('default') ?? AppSettings();
|
||||
_ensureLogExists();
|
||||
}
|
||||
|
||||
void _ensureLogExists() {
|
||||
if (!_logBox.containsKey(_todayKey)) {
|
||||
final shalatLogs = <String, ShalatLog>{};
|
||||
for (final p in _fardhuPrayers) {
|
||||
shalatLogs[p.toLowerCase()] = ShalatLog();
|
||||
}
|
||||
|
||||
_logBox.put(
|
||||
_todayKey,
|
||||
DailyWorshipLog(
|
||||
date: _todayKey,
|
||||
shalatLogs: shalatLogs,
|
||||
tilawahLog: TilawahLog(
|
||||
targetValue: _settings.tilawahTargetValue,
|
||||
targetUnit: _settings.tilawahTargetUnit,
|
||||
autoSync: _settings.tilawahAutoSync,
|
||||
),
|
||||
dzikirLog: _settings.trackDzikir ? DzikirLog() : null,
|
||||
puasaLog: _settings.trackPuasa ? PuasaLog() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DailyWorshipLog get _todayLog => _logBox.get(_todayKey)!;
|
||||
|
||||
void _recalculateProgress() {
|
||||
final log = _todayLog;
|
||||
|
||||
// Lazily attach Dzikir and Puasa if user toggles them mid-day
|
||||
if (_settings.trackDzikir && log.dzikirLog == null) log.dzikirLog = DzikirLog();
|
||||
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
|
||||
|
||||
int total = 0;
|
||||
int completed = 0;
|
||||
|
||||
// Shalat
|
||||
for (final p in _fardhuPrayers) {
|
||||
final pKey = p.toLowerCase();
|
||||
final sLog = log.shalatLogs[pKey];
|
||||
if (sLog != null) {
|
||||
total++;
|
||||
if (sLog.completed) completed++;
|
||||
|
||||
if (hasQabliyah(pKey, _settings.rawatibLevel)) {
|
||||
total++;
|
||||
if (sLog.qabliyah == true) completed++;
|
||||
}
|
||||
if (hasBadiyah(pKey, _settings.rawatibLevel)) {
|
||||
total++;
|
||||
if (sLog.badiyah == true) completed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tilawah
|
||||
if (log.tilawahLog != null) {
|
||||
total++;
|
||||
if (log.tilawahLog!.isCompleted) completed++;
|
||||
}
|
||||
|
||||
// Dzikir
|
||||
if (_settings.trackDzikir && log.dzikirLog != null) {
|
||||
total += 2;
|
||||
if (log.dzikirLog!.pagi) completed++;
|
||||
if (log.dzikirLog!.petang) completed++;
|
||||
}
|
||||
|
||||
// Puasa
|
||||
if (_settings.trackPuasa && log.puasaLog != null) {
|
||||
total++;
|
||||
if (log.puasaLog!.completed) completed++;
|
||||
}
|
||||
|
||||
log.totalItems = total;
|
||||
log.completedCount = completed;
|
||||
log.completionPercent = total > 0 ? completed / total : 0.0;
|
||||
log.save();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
bool hasQabliyah(String prayer, int level) {
|
||||
if (level == 0) return false;
|
||||
if (prayer == 'subuh') return true;
|
||||
if (prayer == 'dzuhur') return true;
|
||||
if (prayer == 'ashar') return level == 2; // Ghairu Muakkad
|
||||
if (prayer == 'maghrib') return level == 2; // Ghairu Muakkad
|
||||
if (prayer == 'isya') return level == 2; // Ghairu Muakkad
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasBadiyah(String prayer, int level) {
|
||||
if (level == 0) return false;
|
||||
if (prayer == 'subuh') return false;
|
||||
if (prayer == 'dzuhur') return true;
|
||||
if (prayer == 'ashar') return false;
|
||||
if (prayer == 'maghrib') return true;
|
||||
if (prayer == 'isya') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final log = _todayLog;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Ibadah Harian'),
|
||||
Text(
|
||||
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildProgressCard(log, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('SHOLAT FARDHU & RAWATIB'),
|
||||
const SizedBox(height: 12),
|
||||
..._fardhuPrayers.map((p) => _buildShalatCard(p, isDark)).toList(),
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('TILAWAH AL-QURAN'),
|
||||
const SizedBox(height: 12),
|
||||
_buildTilawahCard(isDark),
|
||||
if (_settings.trackDzikir || _settings.trackPuasa) ...[
|
||||
const SizedBox(height: 24),
|
||||
_sectionLabel('AMALAN TAMBAHAN'),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (_settings.trackDzikir) _buildDzikirCard(isDark),
|
||||
if (_settings.trackPuasa) _buildPuasaCard(isDark),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.sage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(DailyWorshipLog log, bool isDark) {
|
||||
final percent = log.completionPercent;
|
||||
final remaining = log.totalItems - log.completedCount;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : const Color(0xFF2B3441),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"POIN HARI INI",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.primary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.stars, color: AppColors.primary, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${log.totalPoints} pts',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${(percent * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'Selesai',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppProgressBar(
|
||||
value: percent,
|
||||
height: 8,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.15),
|
||||
fillColor: AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
remaining == 0 && log.totalItems > 0
|
||||
? 'MasyaAllah! Poin maksimal tercapai hari ini! 🎉'
|
||||
: 'Kumpulkan poin lebih banyak dengan Sholat di Masjid dan amalan sunnah lainnya!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShalatCard(String prayerName, bool isDark) {
|
||||
final pKey = prayerName.toLowerCase();
|
||||
final log = _todayLog.shalatLogs[pKey];
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
final hasQab = hasQabliyah(pKey, _settings.rawatibLevel);
|
||||
final hasBad = hasBadiyah(pKey, _settings.rawatibLevel);
|
||||
final isCompleted = log.completed;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.mosque, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
title: Text(
|
||||
'Sholat $prayerName',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: log.location != null
|
||||
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
|
||||
: null,
|
||||
trailing: _CustomCheckbox(
|
||||
value: isCompleted,
|
||||
onChanged: (v) {
|
||||
log.completed = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
children: [
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// Location Radio
|
||||
Row(
|
||||
children: [
|
||||
const Text('Pelaksanaan:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Masjid', log, () {
|
||||
log.location = 'Masjid';
|
||||
log.completed = true; // Auto-check parent
|
||||
_recalculateProgress();
|
||||
}),
|
||||
const SizedBox(width: 16),
|
||||
_radioOption('Rumah', log, () {
|
||||
log.location = 'Rumah';
|
||||
log.completed = true; // Auto-check parent
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
if (hasQab || hasBad) const SizedBox(height: 12),
|
||||
if (hasQab)
|
||||
_sunnahRow('Qabliyah $prayerName', log.qabliyah ?? false, (v) {
|
||||
log.qabliyah = v;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
if (hasBad)
|
||||
_sunnahRow('Ba\'diyah $prayerName', log.badiyah ?? false, (v) {
|
||||
log.badiyah = v;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioOption(String title, ShalatLog log, VoidCallback onTap) {
|
||||
final selected = log.location == title;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 18,
|
||||
color: selected ? AppColors.primary : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sunnahRow(String title, bool value, ValueChanged<bool?> onChanged) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14)),
|
||||
_CustomCheckbox(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTilawahCard(bool isDark) {
|
||||
final log = _todayLog.tilawahLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Row 1: Target + Checkbox ──
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: log.isCompleted
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.menu_book, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Tilawah',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
|
||||
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Target: ${log.targetValue} ${log.targetUnit}',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_CustomCheckbox(
|
||||
value: log.targetCompleted,
|
||||
onChanged: (v) {
|
||||
log.targetCompleted = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// ── Row 2: Ayat Tracker ──
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_stories, size: 18, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sudah Baca: ${log.rawAyatRead} Ayat',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (log.autoSync)
|
||||
Tooltip(
|
||||
message: 'Sinkron dari Al-Quran',
|
||||
child: Icon(Icons.sync, size: 16, color: AppColors.primary),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: log.rawAyatRead > 0
|
||||
? () {
|
||||
log.rawAyatRead--;
|
||||
_recalculateProgress();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 20, color: AppColors.primary),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
log.rawAyatRead++;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDzikirCard(bool isDark) {
|
||||
final log = _todayLog.dzikirLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_sunnahRow('Dzikir Pagi', log.pagi, (v) {
|
||||
log.pagi = v ?? false;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
_sunnahRow('Dzikir Petang', log.petang, (v) {
|
||||
log.petang = v ?? false;
|
||||
_recalculateProgress();
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPuasaCard(bool isDark) {
|
||||
final log = _todayLog.puasaLog;
|
||||
if (log == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.nightlight_round, size: 20, color: AppColors.sage),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
DropdownButton<String>(
|
||||
value: log.jenisPuasa,
|
||||
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
|
||||
underline: const SizedBox(),
|
||||
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
log.jenisPuasa = v;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CustomCheckbox(
|
||||
value: log.completed,
|
||||
onChanged: (v) {
|
||||
log.completed = v ?? false;
|
||||
_recalculateProgress();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomCheckbox extends StatelessWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
|
||||
const _CustomCheckbox({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: value ? null : Border.all(color: Colors.grey, width: 2),
|
||||
),
|
||||
child: value ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/checklist/presentation/placeholder.dart
Normal file
1
lib/features/checklist/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dashboard/data/.gitkeep
Normal file
0
lib/features/dashboard/data/.gitkeep
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
1
lib/features/dashboard/data/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
210
lib/features/dashboard/data/prayer_times_provider.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/location_service.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
|
||||
/// Represents a single prayer time entry.
|
||||
class PrayerTimeEntry {
|
||||
final String name;
|
||||
final String time; // "HH:mm"
|
||||
final bool isActive;
|
||||
PrayerTimeEntry({
|
||||
required this.name,
|
||||
required this.time,
|
||||
this.isActive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Full day prayer schedule from myQuran API.
|
||||
class DaySchedule {
|
||||
final String cityName;
|
||||
final String province;
|
||||
final String date; // yyyy-MM-dd
|
||||
final String tanggal; // formatted date from API
|
||||
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
|
||||
|
||||
DaySchedule({
|
||||
required this.cityName,
|
||||
required this.province,
|
||||
required this.date,
|
||||
required this.tanggal,
|
||||
required this.times,
|
||||
});
|
||||
|
||||
/// Is this schedule for tomorrow?
|
||||
bool get isTomorrow {
|
||||
final todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
return date.compareTo(todayStr) > 0;
|
||||
}
|
||||
|
||||
/// Get prayer time entries as a list.
|
||||
List<PrayerTimeEntry> get prayerList {
|
||||
final now = DateTime.now();
|
||||
final formatter = DateFormat('HH:mm');
|
||||
final currentTime = formatter.format(now);
|
||||
|
||||
final prayers = [
|
||||
PrayerTimeEntry(name: 'Imsak', time: times['imsak'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Subuh', time: times['subuh'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Terbit', time: times['terbit'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dhuha', time: times['dhuha'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Dzuhur', time: times['dzuhur'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Ashar', time: times['ashar'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Maghrib', time: times['maghrib'] ?? '-'),
|
||||
PrayerTimeEntry(name: 'Isya', time: times['isya'] ?? '-'),
|
||||
];
|
||||
|
||||
// Find the next prayer
|
||||
int activeIndex = -1;
|
||||
if (isTomorrow) {
|
||||
// User specifically requested to show tomorrow's Subuh as upcoming
|
||||
activeIndex = 1; // 0=Imsak, 1=Subuh
|
||||
} else {
|
||||
for (int i = 0; i < prayers.length; i++) {
|
||||
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeIndex >= 0) {
|
||||
prayers[activeIndex] = PrayerTimeEntry(
|
||||
name: prayers[activeIndex].name,
|
||||
time: prayers[activeIndex].time,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
return prayers;
|
||||
}
|
||||
|
||||
/// Get the next prayer name and time.
|
||||
PrayerTimeEntry? get nextPrayer {
|
||||
final list = prayerList;
|
||||
for (final p in list) {
|
||||
if (p.isActive) return p;
|
||||
}
|
||||
// If none active and it's today, all prayers have passed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Jakarta city ID from myQuran API.
|
||||
const _defaultCityId = '58a2fc6ed39fd083f55d4182bf88826d';
|
||||
|
||||
/// Provider for the user's selected city ID (stored in Hive settings).
|
||||
final selectedCityIdProvider = StateProvider<String>((ref) {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').last;
|
||||
}
|
||||
return _defaultCityId;
|
||||
});
|
||||
|
||||
/// Provider for today's prayer times using myQuran API.
|
||||
final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
DaySchedule? schedule;
|
||||
|
||||
// Try API first
|
||||
final jadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, today);
|
||||
|
||||
if (jadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: jadwal['tanggal'] ?? today,
|
||||
times: jadwal,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all prayers today have passed
|
||||
if (schedule != null && !schedule.isTomorrow && schedule.nextPrayer == null) {
|
||||
// All prayers passed, fetch tomorrow's schedule
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
|
||||
|
||||
final tmrwJadwal =
|
||||
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
|
||||
|
||||
if (tmrwJadwal != null) {
|
||||
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
schedule = DaySchedule(
|
||||
cityName: cityInfo?['kabko'] ?? 'Jakarta',
|
||||
province: cityInfo?['prov'] ?? 'DKI Jakarta',
|
||||
date: tomorrowStr,
|
||||
tanggal: tmrwJadwal['tanggal'] ?? tomorrowStr,
|
||||
times: tmrwJadwal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schedule != null) {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// Fallback to adhan package
|
||||
final position = await LocationService.instance.getCurrentLocation();
|
||||
double lat = position?.latitude ?? -6.2088;
|
||||
double lng = position?.longitude ?? 106.8456;
|
||||
|
||||
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
|
||||
if (result != null) {
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
return DaySchedule(
|
||||
cityName: 'Jakarta',
|
||||
province: 'DKI Jakarta',
|
||||
date: today,
|
||||
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
|
||||
times: {
|
||||
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
|
||||
'subuh': timeFormat.format(result.fajr),
|
||||
'terbit': timeFormat.format(result.sunrise),
|
||||
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
|
||||
'dzuhur': timeFormat.format(result.dhuhr),
|
||||
'ashar': timeFormat.format(result.asr),
|
||||
'maghrib': timeFormat.format(result.maghrib),
|
||||
'isya': timeFormat.format(result.isha),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for monthly prayer schedule (for Imsakiyah screen).
|
||||
final monthlyScheduleProvider =
|
||||
FutureProvider.family<Map<String, Map<String, String>>, String>(
|
||||
(ref, month) async {
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
return MyQuranSholatService.instance.getMonthlySchedule(cityId, month);
|
||||
});
|
||||
|
||||
/// Provider for current city name.
|
||||
final cityNameProvider = FutureProvider<String>((ref) async {
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
final settings = box.get('default');
|
||||
final stored = settings?.lastCityName ?? '';
|
||||
if (stored.contains('|')) {
|
||||
return stored.split('|').first;
|
||||
}
|
||||
|
||||
final cityId = ref.watch(selectedCityIdProvider);
|
||||
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
|
||||
if (info != null) {
|
||||
return '${info['kabko']}, ${info['prov']}';
|
||||
}
|
||||
return 'Kota Jakarta, DKI Jakarta';
|
||||
});
|
||||
0
lib/features/dashboard/domain/.gitkeep
Normal file
0
lib/features/dashboard/domain/.gitkeep
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
1
lib/features/dashboard/domain/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
673
lib/features/dashboard/presentation/dashboard_screen.dart
Normal file
@@ -0,0 +1,673 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/prayer_time_card.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../data/prayer_times_provider.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Timer? _countdownTimer;
|
||||
Duration _countdown = Duration.zero;
|
||||
String _nextPrayerName = '';
|
||||
final ScrollController _prayerScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_prayerScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown(DaySchedule schedule) {
|
||||
_countdownTimer?.cancel();
|
||||
_updateCountdown(schedule);
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_updateCountdown(schedule);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCountdown(DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
if (next != null && next.time != '-') {
|
||||
final parts = next.time.split(':');
|
||||
if (parts.length == 2) {
|
||||
final now = DateTime.now();
|
||||
var target = DateTime(now.year, now.month, now.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]));
|
||||
if (target.isBefore(now)) {
|
||||
target = target.add(const Duration(days: 1));
|
||||
}
|
||||
setState(() {
|
||||
_nextPrayerName = next.name;
|
||||
_countdown = target.difference(now);
|
||||
if (_countdown.isNegative) _countdown = Duration.zero;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatCountdown(Duration d) {
|
||||
final h = d.inHours.toString().padLeft(2, '0');
|
||||
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final prayerTimesAsync = ref.watch(prayerTimesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildHeader(context, isDark),
|
||||
const SizedBox(height: 20),
|
||||
prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule != null) {
|
||||
_startCountdown(schedule);
|
||||
return _buildHeroCard(context, schedule);
|
||||
}
|
||||
return _buildHeroCardPlaceholder(context);
|
||||
},
|
||||
loading: () => _buildHeroCardPlaceholder(context),
|
||||
error: (_, __) => _buildHeroCardPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildPrayerTimesSection(context, prayerTimesAsync),
|
||||
const SizedBox(height: 24),
|
||||
_buildChecklistSummary(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
_buildWeeklyProgress(context, isDark),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, bool isDark) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.primary, width: 2),
|
||||
color: AppColors.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat datang,',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"Assalamu'alaikum",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: Icon(
|
||||
Icons.settings_outlined,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
|
||||
final next = schedule.nextPrayer;
|
||||
final name = _nextPrayerName.isNotEmpty
|
||||
? _nextPrayerName
|
||||
: (next?.name ?? 'Isya');
|
||||
final time = next?.time ?? '--:--';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -20,
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule,
|
||||
size: 16,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'SHOLAT BERIKUTNYA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$name — $time',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Hitung mundur: ${_formatCountdown(_countdown)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// City name
|
||||
Text(
|
||||
'📍 ${schedule.cityName}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/tools/qibla'),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.explore, size: 18, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Arah Kiblat',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.volume_up,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrayerTimesSection(
|
||||
BuildContext context, AsyncValue<DaySchedule?> prayerTimesAsync) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true
|
||||
? 'Jadwal Sholat Besok'
|
||||
: 'Jadwal Sholat Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: prayerTimesAsync.when(
|
||||
data: (schedule) {
|
||||
if (schedule == null) return const SizedBox();
|
||||
final prayers = schedule.prayerList.where(
|
||||
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
|
||||
.contains(p.name),
|
||||
).toList();
|
||||
return ListView.separated(
|
||||
controller: _prayerScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: prayers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, i) {
|
||||
final p = prayers[i];
|
||||
final icon = _prayerIcon(p.name);
|
||||
// Auto-scroll to active prayer on first build
|
||||
if (p.isActive && i > 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_prayerScrollController.hasClients) {
|
||||
final targetOffset = i * 124.0; // 112 width + 12 gap
|
||||
_prayerScrollController.animateTo(
|
||||
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return PrayerTimeCard(
|
||||
prayerName: p.name,
|
||||
time: p.time,
|
||||
icon: icon,
|
||||
isActive: p.isActive,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
const Center(child: Text('Gagal memuat jadwal')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _prayerIcon(String name) {
|
||||
switch (name) {
|
||||
case 'Subuh':
|
||||
return Icons.wb_twilight;
|
||||
case 'Dzuhur':
|
||||
return Icons.wb_sunny;
|
||||
case 'Ashar':
|
||||
return Icons.filter_drama;
|
||||
case 'Maghrib':
|
||||
return Icons.wb_twilight;
|
||||
case 'Isya':
|
||||
return Icons.dark_mode;
|
||||
default:
|
||||
return Icons.schedule;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChecklistSummary(BuildContext context, bool isDark) {
|
||||
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final log = box.get(todayKey);
|
||||
|
||||
final points = log?.totalPoints ?? 0;
|
||||
// We can assume a max "excellent" day is around 150 points for the progress ring scale
|
||||
final percent = (points / 150).clamp(0.0, 1.0);
|
||||
|
||||
// Prepare dynamic preview lines
|
||||
int fardhuCompleted = 0;
|
||||
if (log != null) {
|
||||
fardhuCompleted = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
}
|
||||
|
||||
String amalanText = 'Belum ada data';
|
||||
if (log != null) {
|
||||
List<String> aList = [];
|
||||
if (log.tilawahLog?.isCompleted == true) aList.add('Tilawah');
|
||||
if (log.puasaLog?.completed == true) aList.add('Puasa');
|
||||
if (log.dzikirLog?.pagi == true) aList.add('Dzikir');
|
||||
if (aList.isNotEmpty) {
|
||||
amalanText = aList.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Poin Ibadah Hari Ini',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Kumpulkan poin dengan konsisten!',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: percent,
|
||||
strokeWidth: 4,
|
||||
backgroundColor:
|
||||
AppColors.primary.withValues(alpha: 0.15),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary),
|
||||
),
|
||||
Text(
|
||||
'$points',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
|
||||
const SizedBox(height: 8),
|
||||
_checklistPreviewItem(
|
||||
context, isDark, 'Amalan Selesai', amalanText, points > 50),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/checklist'),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Lihat Semua Checklist',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _checklistPreviewItem(BuildContext context, bool isDark, String title,
|
||||
String subtitle, bool completed) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
completed ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: AppColors.primary,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyProgress(BuildContext context, bool isDark) {
|
||||
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Reverse so today is on the far right (index 6)
|
||||
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
|
||||
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
final weekPoints = <int>[];
|
||||
for (final d in last7Days) {
|
||||
final k = DateFormat('yyyy-MM-dd').format(d);
|
||||
final l = box.get(k);
|
||||
weekPoints.add(l?.totalPoints ?? 0);
|
||||
}
|
||||
|
||||
// Find the max points acquired this week to scale the bars, with a minimum floor of 50
|
||||
final maxPts = weekPoints.reduce((a, b) => a > b ? a : b).clamp(50, 300);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Progres Poin Mingguan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(7, (i) {
|
||||
final val = weekPoints[i];
|
||||
final ratio = (val / maxPts).clamp(0.1, 1.0);
|
||||
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 80 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: val > 0
|
||||
? AppColors.primary.withValues(
|
||||
alpha: 0.2 + ratio * 0.8)
|
||||
: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
daysLabels[last7Days[i].weekday - 1], // Correct localized day
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
|
||||
color: i == 6
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/dzikir/presentation/.gitkeep
Normal file
0
lib/features/dzikir/presentation/.gitkeep
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal file
306
lib/features/dzikir/presentation/dzikir_screen.dart
Normal file
@@ -0,0 +1,306 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/dzikir_counter.dart';
|
||||
|
||||
class DzikirScreen extends ConsumerStatefulWidget {
|
||||
const DzikirScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DzikirScreen> createState() => _DzikirScreenState();
|
||||
}
|
||||
|
||||
class _DzikirScreenState extends ConsumerState<DzikirScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<Map<String, dynamic>> _pagiItems = [];
|
||||
List<Map<String, dynamic>> _petangItems = [];
|
||||
late Box<DzikirCounter> _counterBox;
|
||||
late String _todayKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
|
||||
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final pagiJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_pagi.json');
|
||||
final petangJson =
|
||||
await rootBundle.loadString('assets/dzikir/dzikir_petang.json');
|
||||
setState(() {
|
||||
_pagiItems = List<Map<String, dynamic>>.from(json.decode(pagiJson));
|
||||
_petangItems = List<Map<String, dynamic>>.from(json.decode(petangJson));
|
||||
});
|
||||
}
|
||||
|
||||
DzikirCounter _getCounter(String dzikirId, int target) {
|
||||
final key = '${dzikirId}_$_todayKey';
|
||||
return _counterBox.get(key) ??
|
||||
DzikirCounter(
|
||||
dzikirId: dzikirId,
|
||||
date: _todayKey,
|
||||
count: 0,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void _increment(String dzikirId, int target) {
|
||||
final key = '${dzikirId}_$_todayKey';
|
||||
var counter = _counterBox.get(key);
|
||||
if (counter == null) {
|
||||
counter = DzikirCounter(
|
||||
dzikirId: dzikirId,
|
||||
date: _todayKey,
|
||||
count: 1,
|
||||
target: target,
|
||||
);
|
||||
_counterBox.put(key, counter);
|
||||
} else {
|
||||
if (counter.count < counter.target) {
|
||||
counter.count++;
|
||||
counter.save();
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
// Haptic feedback
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dzikir Pagi & Petang'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Tabs
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
indicatorColor: AppColors.primary,
|
||||
indicatorWeight: 3,
|
||||
labelStyle:
|
||||
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
||||
tabs: const [
|
||||
Tab(text: 'Pagi'),
|
||||
Tab(text: 'Petang'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildDzikirList(context, isDark, _pagiItems, 'pagi',
|
||||
'Dzikir Pagi', 'Dibaca setelah shalat Shubuh hingga terbit matahari'),
|
||||
_buildDzikirList(context, isDark, _petangItems, 'petang',
|
||||
'Dzikir Petang', 'Dibaca setelah shalat Ashar hingga terbenam matahari'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDzikirList(BuildContext context, bool isDark,
|
||||
List<Map<String, dynamic>> items, String prefix, String title, String subtitle) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length + 1, // +1 for header
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = items[index - 1];
|
||||
final dzikirId = '${prefix}_${item['id']}';
|
||||
final target = (item['count'] as num?)?.toInt() ?? 1;
|
||||
final counter = _getCounter(dzikirId, target);
|
||||
final isComplete = counter.count >= counter.target;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isComplete
|
||||
? AppColors.primary.withValues(alpha: 0.3)
|
||||
: (isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: AppColors.cream),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: count badge + number
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
'$target KALI',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(index).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Arabic text
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
item['arabic'] ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Amiri',
|
||||
fontSize: 24,
|
||||
height: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Transliteration
|
||||
Text(
|
||||
item['transliteration'] ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Translation
|
||||
Text(
|
||||
'"${item['translation'] ?? ''}"',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Counter button
|
||||
GestureDetector(
|
||||
onTap: () => _increment(dzikirId, target),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isComplete
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isComplete ? Icons.check : Icons.touch_app,
|
||||
size: 18,
|
||||
color: isComplete
|
||||
? AppColors.primary
|
||||
: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${counter.count} / $target',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isComplete
|
||||
? AppColors.primary
|
||||
: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
1
lib/features/dzikir/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
0
lib/features/imsakiyah/presentation/.gitkeep
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
557
lib/features/imsakiyah/presentation/imsakiyah_screen.dart
Normal file
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/app_settings.dart';
|
||||
import '../../../data/services/prayer_service.dart';
|
||||
import '../../../data/services/myquran_sholat_service.dart';
|
||||
import '../../dashboard/data/prayer_times_provider.dart';
|
||||
|
||||
class ImsakiyahScreen extends ConsumerStatefulWidget {
|
||||
const ImsakiyahScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImsakiyahScreen> createState() => _ImsakiyahScreenState();
|
||||
}
|
||||
|
||||
class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
|
||||
int _selectedMonthIndex = 0;
|
||||
late List<_MonthOption> _months;
|
||||
late AppSettings _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final box = Hive.box<AppSettings>(HiveBoxes.settings);
|
||||
_settings = box.get('default') ?? AppSettings();
|
||||
_months = _generateMonths();
|
||||
// Find current month
|
||||
final now = DateTime.now();
|
||||
for (int i = 0; i < _months.length; i++) {
|
||||
if (_months[i].month == now.month && _months[i].year == now.year) {
|
||||
_selectedMonthIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<_MonthOption> _generateMonths() {
|
||||
final now = DateTime.now();
|
||||
final list = <_MonthOption>[];
|
||||
for (int offset = -2; offset <= 3; offset++) {
|
||||
final date = DateTime(now.year, now.month + offset, 1);
|
||||
list.add(_MonthOption(
|
||||
label: DateFormat('MMMM yyyy').format(date),
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
|
||||
final selected = _months[_selectedMonthIndex];
|
||||
final daysInMonth =
|
||||
DateTime(selected.year, selected.month + 1, 0).day;
|
||||
final rows = <_DayRow>[];
|
||||
|
||||
for (int d = 1; d <= daysInMonth; d++) {
|
||||
final date = DateTime(selected.year, selected.month, d);
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
|
||||
if (apiData != null && apiData.containsKey(dateStr)) {
|
||||
final times = apiData[dateStr]!;
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: times['subuh'] ?? '-',
|
||||
sunrise: times['terbit'] ?? '-',
|
||||
dhuhr: times['dzuhur'] ?? '-',
|
||||
asr: times['ashar'] ?? '-',
|
||||
maghrib: times['maghrib'] ?? '-',
|
||||
isha: times['isya'] ?? '-',
|
||||
));
|
||||
} else {
|
||||
final times =
|
||||
PrayerService.instance.getPrayerTimes(-6.2088, 106.8456, date);
|
||||
rows.add(_DayRow(
|
||||
date: date,
|
||||
fajr: DateFormat('HH:mm').format(times.fajr),
|
||||
sunrise: DateFormat('HH:mm').format(times.sunrise),
|
||||
dhuhr: DateFormat('HH:mm').format(times.dhuhr),
|
||||
asr: DateFormat('HH:mm').format(times.asr),
|
||||
maghrib: DateFormat('HH:mm').format(times.maghrib),
|
||||
isha: DateFormat('HH:mm').format(times.isha),
|
||||
));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _showLocationDialog(BuildContext context) {
|
||||
final searchCtrl = TextEditingController();
|
||||
bool isSearching = false;
|
||||
List<Map<String, dynamic>> results = [];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
title: const Text('Cari Kota/Kabupaten'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchCtrl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cth: Jakarta',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if (searchCtrl.text.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(searchCtrl.text.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) async {
|
||||
if (val.trim().isEmpty) return;
|
||||
setDialogState(() => isSearching = true);
|
||||
final res = await MyQuranSholatService.instance
|
||||
.searchCity(val.trim());
|
||||
if (mounted) {
|
||||
setDialogState(() {
|
||||
results = res;
|
||||
isSearching = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isSearching)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (results.isEmpty)
|
||||
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, i) {
|
||||
final city = results[i];
|
||||
return ListTile(
|
||||
title: Text(city['lokasi'] ?? ''),
|
||||
onTap: () {
|
||||
final id = city['id'];
|
||||
final name = city['lokasi'];
|
||||
if (id != null && name != null) {
|
||||
_settings.lastCityName = '$name|$id';
|
||||
_settings.save();
|
||||
|
||||
// Update providers to refresh data
|
||||
ref.invalidate(selectedCityIdProvider);
|
||||
ref.invalidate(cityNameProvider);
|
||||
|
||||
Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final today = DateTime.now();
|
||||
|
||||
final selectedMonth = _months[_selectedMonthIndex];
|
||||
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
|
||||
final cityNameAsync = ref.watch(cityNameProvider);
|
||||
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Kalender Sholat'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Month Selector ──
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: _months.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final isSelected = i == _selectedMonthIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _selectedMonthIndex = i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: isSelected
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_months[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? AppColors.onPrimary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Location Card ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showLocationDialog(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on,
|
||||
color: AppColors.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lokasi Anda',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cityNameAsync.value ?? 'Jakarta, Indonesia',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_more,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Table Header ──
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_headerCell('TGL', flex: 4),
|
||||
_headerCell('SUBUH', flex: 3),
|
||||
_headerCell('SYURUQ', flex: 3),
|
||||
_headerCell('DZUHUR', flex: 3),
|
||||
_headerCell('ASHAR', flex: 3),
|
||||
_headerCell('MAGH', flex: 3),
|
||||
_headerCell('ISYA', flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Table Body ──
|
||||
Expanded(
|
||||
child: monthlyDataAsync.when(
|
||||
data: (apiData) {
|
||||
final rows = _createRows(apiData);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Day column
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) {
|
||||
final rows = _createRows(null); // fallback
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final row = rows[i];
|
||||
final isToday = row.date.day == today.day &&
|
||||
row.date.month == today.month &&
|
||||
row.date.year == today.year;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.surfaceDark
|
||||
: AppColors.surfaceLight),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isToday
|
||||
? null
|
||||
: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.05)
|
||||
: AppColors.cream.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('MMM')
|
||||
.format(row.date)
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: isToday
|
||||
? AppColors.onPrimary
|
||||
.withValues(alpha: 0.7)
|
||||
: AppColors.sage,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_dataCell(row.fajr, isToday, flex: 3),
|
||||
_dataCell(row.sunrise, isToday, flex: 3),
|
||||
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.asr, isToday, flex: 3),
|
||||
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
|
||||
_dataCell(row.isha, isToday, flex: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerCell(String text, {int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dataCell(String value, bool isToday,
|
||||
{bool bold = false, int flex = 1}) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: bold ? FontWeight.w700 : FontWeight.w400,
|
||||
color: isToday ? AppColors.onPrimary : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthOption {
|
||||
final String label;
|
||||
final int month;
|
||||
final int year;
|
||||
_MonthOption({required this.label, required this.month, required this.year});
|
||||
}
|
||||
|
||||
class _DayRow {
|
||||
final DateTime date;
|
||||
final String fajr, sunrise, dhuhr, asr, maghrib, isha;
|
||||
_DayRow({
|
||||
required this.date,
|
||||
required this.fajr,
|
||||
required this.sunrise,
|
||||
required this.dhuhr,
|
||||
required this.asr,
|
||||
required this.maghrib,
|
||||
required this.isha,
|
||||
});
|
||||
}
|
||||
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
1
lib/features/imsakiyah/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/laporan/presentation/.gitkeep
Normal file
0
lib/features/laporan/presentation/.gitkeep
Normal file
566
lib/features/laporan/presentation/laporan_screen.dart
Normal file
566
lib/features/laporan/presentation/laporan_screen.dart
Normal file
@@ -0,0 +1,566 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../app/theme/app_colors.dart';
|
||||
import '../../../core/widgets/progress_bar.dart';
|
||||
import '../../../data/local/hive_boxes.dart';
|
||||
import '../../../data/local/models/daily_worship_log.dart';
|
||||
import '../../../data/local/models/checklist_item.dart';
|
||||
|
||||
class LaporanScreen extends ConsumerStatefulWidget {
|
||||
const LaporanScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LaporanScreen> createState() => _LaporanScreenState();
|
||||
}
|
||||
|
||||
class _LaporanScreenState extends ConsumerState<LaporanScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Get the last 7 days' point data.
|
||||
List<_DayData> _getWeeklyData() {
|
||||
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
final data = <_DayData>[];
|
||||
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||
final log = logBox.get(key);
|
||||
data.add(_DayData(
|
||||
label: DateFormat('E').format(date).substring(0, 3),
|
||||
value: (log?.totalPoints ?? 0).toDouble(), // Use points instead of %
|
||||
isToday: i == 0,
|
||||
));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Get average points for the week.
|
||||
double _weekAverage(List<_DayData> data) {
|
||||
if (data.isEmpty) return 0;
|
||||
final sum = data.fold<double>(0, (s, d) => s + d.value);
|
||||
return sum / data.length;
|
||||
}
|
||||
|
||||
/// Find best and worst performing items.
|
||||
_InsightPair _getInsights() {
|
||||
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
|
||||
final now = DateTime.now();
|
||||
|
||||
final completionCounts = <String, int>{};
|
||||
final totalCounts = <String, int>{};
|
||||
int daysChecked = 0;
|
||||
|
||||
for (int i = 0; i < 7; i++) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final key = DateFormat('yyyy-MM-dd').format(date);
|
||||
final log = logBox.get(key);
|
||||
|
||||
if (log != null && log.totalItems > 0) {
|
||||
daysChecked++;
|
||||
|
||||
// Fardhu
|
||||
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
|
||||
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
|
||||
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
|
||||
|
||||
// Rawatib
|
||||
int rawatibTotal = 0;
|
||||
int rawatibCompleted = 0;
|
||||
for (var sLog in log.shalatLogs.values) {
|
||||
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
|
||||
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
|
||||
}
|
||||
if (rawatibTotal > 0) {
|
||||
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
|
||||
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
|
||||
}
|
||||
|
||||
// Tilawah
|
||||
if (log.tilawahLog != null) {
|
||||
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
|
||||
if (log.tilawahLog!.isCompleted) {
|
||||
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dzikir
|
||||
if (log.dzikirLog != null) {
|
||||
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
|
||||
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
|
||||
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
|
||||
}
|
||||
|
||||
// Puasa
|
||||
if (log.puasaLog != null) {
|
||||
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
|
||||
if (log.puasaLog!.completed) {
|
||||
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (daysChecked == 0 || totalCounts.isEmpty) {
|
||||
return _InsightPair(
|
||||
best: _InsightItem(title: 'Sholat Fardhu', percent: 0),
|
||||
worst: _InsightItem(title: 'Belum Ada Data', percent: 0),
|
||||
);
|
||||
}
|
||||
|
||||
String bestId = totalCounts.keys.first;
|
||||
String worstId = totalCounts.keys.first;
|
||||
double bestRate = -1.0;
|
||||
double worstRate = 2.0;
|
||||
|
||||
for (final id in totalCounts.keys) {
|
||||
final total = totalCounts[id]!;
|
||||
final completed = completionCounts[id] ?? 0;
|
||||
final rate = completed / total;
|
||||
if (rate > bestRate) {
|
||||
bestRate = rate;
|
||||
bestId = id;
|
||||
}
|
||||
if (rate < worstRate) {
|
||||
worstRate = rate;
|
||||
worstId = id;
|
||||
}
|
||||
}
|
||||
|
||||
final idToTitle = {
|
||||
'fardhu': 'Sholat Fardhu',
|
||||
'rawatib': 'Sholat Rawatib',
|
||||
'tilawah': 'Tilawah Quran',
|
||||
'dzikir': 'Dzikir Harian',
|
||||
'puasa': 'Puasa Sunnah',
|
||||
};
|
||||
|
||||
return _InsightPair(
|
||||
best: _InsightItem(
|
||||
title: idToTitle[bestId] ?? bestId,
|
||||
percent: (bestRate * 100).round(),
|
||||
),
|
||||
worst: _InsightItem(
|
||||
title: idToTitle[worstId] ?? worstId,
|
||||
percent: (worstRate * 100).round(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final weekData = _getWeeklyData();
|
||||
final avgPercent = _weekAverage(weekData);
|
||||
final insights = _getInsights();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Laporan Kualitas Ibadah'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Tab Bar ──
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
indicatorColor: AppColors.primary,
|
||||
indicatorWeight: 3,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'Mingguan'),
|
||||
Tab(text: 'Bulanan'),
|
||||
Tab(text: 'Tahunan'),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── Tab Content ──
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
|
||||
_buildComingSoon(context, 'Bulanan'),
|
||||
_buildComingSoon(context, 'Tahunan'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyView(
|
||||
BuildContext context,
|
||||
bool isDark,
|
||||
List<_DayData> weekData,
|
||||
double avgPercent,
|
||||
_InsightPair insights,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Completion Card ──
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Poin Rata-Rata Harian',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.stars,
|
||||
color: AppColors.primary, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${avgPercent.round()} pt',
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// ── Bar Chart ──
|
||||
SizedBox(
|
||||
height: 140,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: weekData.map((d) {
|
||||
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 120 * ratio,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: AppColors.primary
|
||||
.withValues(alpha: 0.3 + ratio * 0.4),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
d.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: d.isToday
|
||||
? FontWeight.w700
|
||||
: FontWeight.w400,
|
||||
color: d.isToday
|
||||
? AppColors.primary
|
||||
: (isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Insights ──
|
||||
Text('Wawasan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
// Best performing
|
||||
_insightCard(
|
||||
context,
|
||||
isDark,
|
||||
icon: Icons.star,
|
||||
iconBg: AppColors.primary.withValues(alpha: 0.15),
|
||||
iconColor: AppColors.primary,
|
||||
label: 'PALING RAJIN',
|
||||
title: insights.best.title,
|
||||
percent: insights.best.percent,
|
||||
percentColor: AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Needs improvement
|
||||
_insightCard(
|
||||
context,
|
||||
isDark,
|
||||
icon: Icons.trending_up,
|
||||
iconBg: const Color(0xFFFFF3E0),
|
||||
iconColor: Colors.orange,
|
||||
label: 'PERLU DITINGKATKAN',
|
||||
title: insights.worst.title,
|
||||
percent: insights.worst.percent,
|
||||
percentColor: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Motivational Quote ──
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.08)
|
||||
: const Color(0xFFF5F9F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'❝',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
color: AppColors.primary,
|
||||
height: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'"Amal yang paling dicintai Allah adalah yang paling konsisten, meskipun sedikit."',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.5,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'— Shahih Bukhari',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _insightCard(
|
||||
BuildContext context,
|
||||
bool isDark, {
|
||||
required IconData icon,
|
||||
required Color iconBg,
|
||||
required Color iconColor,
|
||||
required String label,
|
||||
required String title,
|
||||
required int percent,
|
||||
required Color percentColor,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.primary.withValues(alpha: 0.1)
|
||||
: AppColors.cream,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: iconColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: isDark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$percent%',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: percentColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComingSoon(BuildContext context, String period) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bar_chart,
|
||||
size: 48, color: AppColors.primary.withValues(alpha: 0.3)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Laporan $period',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Segera hadir',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? AppColors.textSecondaryDark
|
||||
: AppColors.textSecondaryLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayData {
|
||||
final String label;
|
||||
final double value;
|
||||
final bool isToday;
|
||||
_DayData({required this.label, required this.value, this.isToday = false});
|
||||
}
|
||||
|
||||
class _InsightItem {
|
||||
final String title;
|
||||
final int percent;
|
||||
_InsightItem({required this.title, required this.percent});
|
||||
}
|
||||
|
||||
class _InsightPair {
|
||||
final _InsightItem best;
|
||||
final _InsightItem worst;
|
||||
_InsightPair({required this.best, required this.worst});
|
||||
}
|
||||
1
lib/features/laporan/presentation/placeholder.dart
Normal file
1
lib/features/laporan/presentation/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/features/qibla/presentation/.gitkeep
Normal file
0
lib/features/qibla/presentation/.gitkeep
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user