diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 00000000..e9a36a4f --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,402 @@ +# OnTime Flutter Project Architecture + +This document provides a comprehensive overview of the OnTime Flutter project's architecture, folder structure, and design patterns. + +## πŸ—οΈ Architecture Overview + +OnTime follows **Clean Architecture** principles with a clear separation of concerns across three main layers: + +- **Presentation Layer**: UI components, state management (BLoC/Cubit), and navigation +- **Domain Layer**: Business logic, entities, use cases, and repository interfaces +- **Data Layer**: Repository implementations, data sources (local/remote), and data models + +### Architecture Diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PRESENTATION LAYER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β€’ Screens & Components β”‚ +β”‚ β€’ BLoC/Cubit (State Management) β”‚ +β”‚ β€’ Navigation (GoRouter) β”‚ +β”‚ β€’ Theme & Localization β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DOMAIN LAYER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β€’ Entities (Business Objects) β”‚ +β”‚ β€’ Use Cases (Business Logic) β”‚ +β”‚ β€’ Repository Interfaces β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DATA LAYER β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β€’ Repository Implementations β”‚ +β”‚ β€’ Data Sources (Remote API, Local Database) β”‚ +β”‚ β€’ Data Models (JSON Serialization) β”‚ +β”‚ β€’ Database Tables & DAOs β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## πŸ“ Project Structure + +``` +lib/ +β”œβ”€β”€ πŸ“± presentation/ # UI Layer (Screens, Widgets, State Management) +β”‚ β”œβ”€β”€ alarm/ # Alarm and timer functionality +β”‚ β”œβ”€β”€ app/ # Main app setup and global BLoC +β”‚ β”œβ”€β”€ calendar/ # Calendar views and components +β”‚ β”œβ”€β”€ early_late/ # Early/late arrival screens +β”‚ β”œβ”€β”€ home/ # Home screen and dashboard +β”‚ β”œβ”€β”€ login/ # Authentication screens +β”‚ β”œβ”€β”€ my_page/ # User profile and settings +β”‚ β”œβ”€β”€ notification_allow/ # Notification permission +β”‚ β”œβ”€β”€ onboarding/ # User onboarding flow +β”‚ β”œβ”€β”€ schedule_create/ # Schedule creation and editing +β”‚ └── shared/ # Shared UI components and utilities +β”‚ β”œβ”€β”€ components/ # Reusable widgets +β”‚ β”œβ”€β”€ router/ # Navigation configuration +β”‚ β”œβ”€β”€ theme/ # App theming +β”‚ └── utils/ # UI utilities +β”‚ +β”œβ”€β”€ 🏒 domain/ # Business Logic Layer +β”‚ β”œβ”€β”€ entities/ # Business objects (User, Schedule, etc.) +β”‚ β”œβ”€β”€ repositories/ # Repository interfaces +β”‚ └── use-cases/ # Business logic operations +β”‚ +β”œβ”€β”€ πŸ’Ύ data/ # Data Access Layer +β”‚ β”œβ”€β”€ daos/ # Database Access Objects (Drift) +β”‚ β”œβ”€β”€ data_sources/ # Data source interfaces/implementations +β”‚ β”œβ”€β”€ models/ # API request/response models +β”‚ β”œβ”€β”€ repositories/ # Repository implementations +β”‚ └── tables/ # Database table definitions +β”‚ +β”œβ”€β”€ πŸ”§ core/ # Core Infrastructure +β”‚ β”œβ”€β”€ constants/ # App constants and environment variables +β”‚ β”œβ”€β”€ database/ # Database configuration (Drift) +β”‚ β”œβ”€β”€ di/ # Dependency injection setup (Injectable) +β”‚ β”œβ”€β”€ dio/ # HTTP client configuration +β”‚ β”œβ”€β”€ services/ # Core services (navigation, notifications) +β”‚ └── utils/ # Core utilities and converters +β”‚ +β”œβ”€β”€ 🌍 l10n/ # Localization files +β”œβ”€β”€ firebase_options.dart # Firebase configuration +└── main.dart # Application entry point +``` + +## πŸ› οΈ Technology Stack + +### Core Framework & Language + +- **Flutter**: Cross-platform mobile framework +- **Dart**: Programming language + +### State Management + +- **BLoC/Cubit**: Business Logic Component pattern for state management +- **Riverpod**: Additional state management for specific use cases +- **Equatable**: Value equality for state objects + +### Dependency Injection + +- **Injectable**: Code generation for dependency injection +- **GetIt**: Service locator pattern + +### Database & Persistence + +- **Drift**: Type-safe SQL database library +- **SharedPreferences**: Simple key-value storage +- **FlutterSecureStorage**: Secure token storage + +### Networking + +- **Dio**: HTTP client with interceptors +- **JSON Annotation**: JSON serialization code generation + +### Navigation + +- **GoRouter**: Declarative routing solution + +### UI Components + +- **Material Design**: Google's design system +- **Flutter SVG**: SVG asset support +- **TableCalendar**: Calendar widget + +### Authentication + +- **Google Sign-In**: Google OAuth integration +- **Kakao SDK**: Kakao social login +- **Firebase Auth**: Authentication backend + +### Notifications + +- **Firebase Messaging**: Push notifications +- **Flutter Local Notifications**: Local notifications + +### Development Tools + +- **Freezed**: Code generation for immutable classes +- **Build Runner**: Code generation runner +- **Widgetbook**: Component development environment + +## πŸ“‹ Architecture Patterns + +### 1. Clean Architecture Layers + +#### **Presentation Layer** + +- **Screens**: Full-screen widgets representing app pages +- **Components**: Reusable UI widgets +- **BLoC/Cubit**: State management following BLoC pattern +- **Navigation**: Declarative routing with GoRouter + +#### **Domain Layer** + +- **Entities**: Core business objects using Freezed for immutability +- **Use Cases**: Single-responsibility business logic operations +- **Repository Interfaces**: Contracts for data access + +#### **Data Layer** + +- **Repository Implementations**: Concrete implementations of domain interfaces +- **Data Sources**: Abstraction over remote APIs and local storage +- **Models**: Data transfer objects with JSON serialization + +### 2. Dependency Injection + +```dart +// Injectable annotation for automatic registration +@Injectable() +class UserRepository implements UserRepositoryInterface { + // Implementation +} + +// GetIt service locator configuration +final getIt = GetIt.instance; + +@InjectableInit() +void configureDependencies() => getIt.init(); +``` + +### 3. State Management with BLoC + +```dart +// BLoC for handling user authentication state +@Injectable() +class AppBloc extends Bloc { + AppBloc(this._streamUserUseCase, this._signOutUseCase) + : super(AppState(user: const UserEntity.empty())) { + on(_onUserSubscriptionRequested); + on(_onSignOutPressed); + } +} +``` + +### 4. Data Models with JSON Serialization + +```dart +@JsonSerializable() +class GetUserResponseModel { + final int userId; + final String email; + final String name; + + // Conversion to domain entity + UserEntity toEntity() { + return UserEntity( + id: userId.toString(), + email: email, + name: name, + ); + } +} +``` + +### 5. Database Layer with Drift + +```dart +@DriftDatabase(tables: [Users, Schedules, Places], daos: [UserDao, ScheduleDao]) +class AppDatabase extends _$AppDatabase { + @override + int get schemaVersion => 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (Migrator m) async => await m.createAll(), + ); +} +``` + +## πŸ”„ Data Flow + +### 1. User Interaction Flow + +``` +User Input β†’ Widget β†’ BLoC Event β†’ Use Case β†’ Repository β†’ Data Source β†’ API/DB + ↓ +Widget ← BLoC State ← Use Case ← Repository ← Data Source ← Response +``` + +### 2. Authentication Flow + +``` +Login Screen β†’ AppBloc β†’ SignInUseCase β†’ UserRepository β†’ AuthDataSource β†’ API + ↓ +Navigation ← AppBloc ← UserEntity ← UserRepository ← AuthDataSource ← Token +``` + +### 3. Schedule Management Flow + +``` +Schedule Form β†’ ScheduleBloc β†’ CreateScheduleUseCase β†’ ScheduleRepository + ↓ +Database ← ScheduleDao ← ScheduleRepository ← ScheduleEntity +``` + +## 🎯 Key Features Architecture + +### 1. **Authentication System** + +- **Google OAuth** and **Kakao Login** integration +- **JWT token** management with secure storage +- **Stream-based** user state management +- **Automatic token refresh** via interceptors + +### 2. **Schedule Management** + +- **CRUD operations** for schedules +- **Calendar integration** with multiple view modes +- **Preparation time calculation** and management +- **Real-time synchronization** between local and remote data +- **Automatic timer system** for schedule start notifications + +πŸ“– **Detailed Documentation**: For comprehensive information about the automatic timer system, see [Schedule Timer System](./Schedule-Timer-System.md) + +### 3. **Notification System** + +- **Firebase push notifications** for schedule reminders +- **Local notifications** for preparation alerts +- **Permission handling** for notification access + +### 4. **Offline Support** + +- **Local database** with Drift for offline data access +- **Synchronization strategy** for online/offline data consistency +- **Caching mechanisms** for improved performance + +## πŸ§ͺ Testing Strategy + +### Structure + +``` +test/ +β”œβ”€β”€ config/ # Test configuration +β”œβ”€β”€ data/ # Data layer tests +└── helpers/ # Test utilities +``` + +### Testing Approach + +- **Unit Tests**: Use cases, repositories, and business logic +- **Widget Tests**: UI components and screen interactions +- **Integration Tests**: End-to-end user flows +- **Mocking**: Using Mockito for external dependencies + +## πŸ“¦ Build & Deployment + +### Development Environment + +- **Flutter SDK**: ^3.5.4 +- **Dart SDK**: Latest stable +- **Build Tools**: build_runner for code generation + +### Code Generation Commands + +```bash +# Generate all code (models, dependency injection, etc.) +dart run build_runner build + +# Watch for changes and regenerate +dart run build_runner watch +``` + +### Platform Support + +- **Android**: Native Android build +- **iOS**: Native iOS build +- **Web**: Progressive Web App support + +## πŸ”§ Development Guidelines + +### 1. **Code Organization** + +- Follow **Clean Architecture** principles +- Separate concerns across layers +- Use **feature-based** folder structure in presentation layer + +### 2. **State Management** + +- Use **BLoC pattern** for complex state management +- Use **Cubit** for simpler state scenarios +- Keep business logic in **use cases**, not in BLoCs + +### 3. **Data Handling** + +- Always use **entities** in the domain layer +- Convert **models to entities** at repository boundaries +- Implement **proper error handling** throughout the layers + +### 4. **Naming Conventions** + +- **Entities**: `UserEntity`, `ScheduleEntity` +- **Use Cases**: `GetUserUseCase`, `CreateScheduleUseCase` +- **Repositories**: `UserRepository`, `ScheduleRepository` +- **BLoCs**: `UserBloc`, `ScheduleFormBloc` +- **Models**: `GetUserResponseModel`, `CreateScheduleRequestModel` + +### 5. **Dependencies** + +- Register all dependencies using **@Injectable()** annotation +- Use **interfaces** for repository contracts +- Avoid direct dependencies between layers + +## πŸš€ Getting Started for New Developers + +1. **Setup Environment** + + - Install Flutter SDK + - Configure IDE (VS Code/Android Studio) + - Run `flutter pub get` to install dependencies + +2. **Code Generation** + + - Run `dart run build_runner build` + - This generates dependency injection, JSON serialization, and Freezed code + +3. **Database Setup** + + - Database migrations are handled automatically + - Local database files are created on first run + +4. **Understanding the Flow** + + - Start with `main.dart` to understand app initialization + - Explore `presentation/app/` for global app state + - Check `domain/use-cases/` for business logic + - Review `data/repositories/` for data access patterns + +5. **Making Changes** + - Create new features following the existing layer structure + - Add new entities in `domain/entities/` + - Implement use cases in `domain/use-cases/` + - Create repository interfaces and implementations + - Build UI components in `presentation/` + +--- + +_This architecture documentation is maintained alongside the codebase. Please update it when making significant architectural changes._ diff --git a/docs/Git.md b/docs/Git.md new file mode 100644 index 00000000..ee9fc577 --- /dev/null +++ b/docs/Git.md @@ -0,0 +1,99 @@ +## Git Strategy + +## Commit Message Formats + +### Default +
+<type>(<optional scope>): <description>
+empty separator line
+<optional body>
+empty separator line
+<optional footer>
+
+ +### Types +* API relevant changes + * `feat` Commits, that add or remove a new feature + * `fix` Commits, that fixes a bug +* `refactor` Commits, that rewrite/restructure your code, however, does not change any API behavior + * `perf` Commits are special `refactor` commits, that improve performance +* `style` Commits, that do not affect the meaning (white-space, formatting, missing semi-colons, etc) +* `test` Commits, that add missing tests or correct existing tests +* `docs` Commits, that affect documentation only +* `build` Commits affect build components like build tool, ci pipeline, dependencies, project version, ... +* `ops` Commits affect operational components like infrastructure, deployment, backup, recovery, ... +* `chore` Miscellaneous commits e.g. modifying `.gitignore` + +### Scopes +The `scope` provides additional contextual information. +* Is an **optional** part of the format +* Allowed Scopes depends on the specific project +* Don't use issue identifiers as scopes + +### Breaking Changes Indicator +Breaking changes should be indicated by an `!` before the `:` in the subject line e.g. `feat(api)!: remove status endpoint` +* Is an **optional** part of the format + +### Description +The `description` contains a concise description of the change. +* Is a **mandatory** part of the format +* Use the imperative, present tense: "change" not "changed" nor "changes" + * Think of `This commit will...` or `This commit should...` +* Don't capitalize the first letter +* No dot (`.`) at the end + +### Body +The `body` should include the motivation for the change and contrast this with previous behavior. +* Is an **optional** part of the format +* Use the imperative, present tense: "change" not "changed" nor "changes" +* This is the place to mention issue identifiers and their relations + +### Footer +The `footer` should contain any information about **Breaking Changes** and is also the place to **reference Issues** that this commit refers to. +* Is an **optional** part of the format +* **optionally** reference an issue by its id. +* **Breaking Changes** should start with the word `BREAKING CHANGES:` followed by space or two newlines. The rest of the commit message is then used for this. + + +### Examples +* ``` + feat: add email notifications on new direct messages + ``` +* ``` + feat(shopping cart): add the amazing button + ``` +* ``` + feat!: remove ticket list endpoint + + refers to JIRA-1337 + + BREAKING CHANGES: ticket endpoints no longer support listing all entities. + ``` +* ``` + fix(shopping-cart): prevent ordering an empty shopping cart + ``` +* ``` + fix(api): fix the wrong calculation of the request body checksum + ``` +* ``` + fix: add a missing parameter to a service call + + The error occurred because of . + ``` +* ``` + perf: decrease memory footprint to determine uniqe visitors by using HyperLogLog + ``` +* ``` + build: update dependencies + ``` +* ``` + build(release): bump version to 1.0.0 + ``` +* ``` + refactor: Implement Fibonacci number calculation as recursion + ``` +* ``` + style: remove empty line + ``` + +--- diff --git a/docs/Home.md b/docs/Home.md new file mode 100644 index 00000000..7de19382 --- /dev/null +++ b/docs/Home.md @@ -0,0 +1,42 @@ +# OnTime Flutter Project Wiki + +Welcome to the OnTime-front project documentation! This wiki contains everything new developers need to understand and contribute to the project. + +## πŸ“š Documentation Index + +### Getting Started +- [Wiki Management Guide](./Wiki-Management.md) - How to modify and upload wiki content +- [Project Architecture](./Architecture.md) - Understanding the project structure +- [Git Workflow](./Git.md) - Git strategy and commit message formats + +### Development Resources +- 🚧 **Getting Started Guide** *(Coming Soon)* - Setup and installation +- 🚧 **Development Guide** *(Coming Soon)* - Development workflow and best practices +- 🚧 **API Documentation** *(Coming Soon)* - Backend API reference +- 🚧 **Testing Guide** *(Coming Soon)* - Testing procedures and guidelines + +## 🎯 Quick Links + +- [Main Repository](https://github.com/DevKor-github/OnTime-front) +- [Issues](https://github.com/DevKor-github/OnTime-front/issues) +- [Pull Requests](https://github.com/DevKor-github/OnTime-front/pulls) +- [Widgetbook](https://on-time-front-widgetbook.web.app/) + +## 🀝 Contributing + +New to the project? Start here: + +1. **Read the [Wiki Management Guide](./Wiki-Management.md)** to understand how documentation works +2. **Review the [Architecture](./Architecture.md)** to understand the project structure +3. **Follow the [Git Workflow](./Git.md)** for consistent development practices +4. **Set up your development environment** *(guide coming soon)* + +## πŸ“ Need Help? + +- Create an issue for bugs or feature requests +- Ask questions in team discussions +- Contribute to documentation improvements + +--- + +*This wiki is maintained by the OnTime development team and integrated with the main repository.* diff --git a/docs/Schedule-Timer-System.md b/docs/Schedule-Timer-System.md new file mode 100644 index 00000000..7ffbd552 --- /dev/null +++ b/docs/Schedule-Timer-System.md @@ -0,0 +1,132 @@ +# Schedule Timer System + +The OnTime app includes an automatic timer system within the `ScheduleBloc` that manages precise timing for schedule start notifications. This system ensures that schedule start events are triggered at the exact scheduled time. + +## 🎯 Overview + +The timer system automatically: + +- Starts when an upcoming schedule is received +- Triggers a `ScheduleStarted` event at exactly x minute 00 seconds +- Handles proper cleanup and timer management +- Ensures thread-safety and prevents memory leaks + +## πŸ”„ Timer Flow + +```mermaid +sequenceDiagram + participant UC as UseCase + participant SB as ScheduleBloc + participant Timer as Timer + participant State as State + + UC->>SB: ScheduleUpcomingReceived(schedule) + SB->>SB: Cancel existing timer + SB->>SB: _startScheduleTimer(schedule) + SB->>Timer: Create Timer(targetTime) + Note over Timer: Timer set for schedule.scheduleTime
at x minute 00 seconds + SB->>State: emit(ScheduleState.upcoming/ongoing) + + Timer-->>SB: Timer fires at exact minute + SB->>SB: add(ScheduleStarted()) + SB->>SB: _onScheduleStarted() + SB->>State: emit(ScheduleState.started) + + Note over SB: Timer cleanup on close()
or new schedule received +``` + +## πŸ“‹ Implementation Details + +### Key Components + +1. **Timer Management** + + - `_scheduleStartTimer`: Dart Timer instance that handles the countdown + - `_currentScheduleId`: Tracks the active schedule to prevent stale events + +2. **Event Handling** + + - `ScheduleUpcomingReceived`: Triggers timer setup + - `ScheduleStarted`: Fired when timer completes + +3. **State Transitions** + - `upcoming` β†’ `started`: When timer fires for upcoming schedules + - `ongoing` β†’ `started`: When timer fires for preparation-in-progress schedules + +### Timer Calculation + +The timer calculates the exact target time as: + +```dart +final targetTime = DateTime( + scheduleTime.year, + scheduleTime.month, + scheduleTime.day, + scheduleTime.hour, + scheduleTime.minute, + 0, // Always trigger at 00 seconds + 0, // 0 milliseconds +); +``` + +### Safety Features + +- **Timer Cancellation**: Previous timers are automatically cancelled when new schedules arrive +- **Bloc State Validation**: Timer callbacks verify the bloc is still active before firing events +- **Schedule ID Matching**: Events only fire for the currently tracked schedule +- **Proper Cleanup**: All timers are cancelled when the bloc is disposed + +## πŸ›‘οΈ Error Handling + +The system includes several safety mechanisms: + +1. **Past Schedule Protection**: Timers are not set for schedules in the past +2. **Bloc Lifecycle Management**: Timer callbacks check `isClosed` before adding events +3. **Memory Leak Prevention**: All timers are properly cancelled in `close()` +4. **Race Condition Prevention**: Schedule ID tracking prevents stale timer events + +## πŸ“± Usage Example + +The timer system works automatically within the `ScheduleBloc`: + +```dart +// When a new schedule is received +bloc.add(ScheduleSubscriptionRequested()); + +// The bloc will: +// 1. Listen for upcoming schedules +// 2. Automatically start timers for each schedule +// 3. Emit ScheduleStarted events at the exact scheduled time +// 4. Transition to 'started' state + +// Listen for state changes +bloc.stream.listen((state) { + if (state.status == ScheduleStatus.started) { + // Handle schedule start (e.g., show notification, start tracking) + } +}); +``` + +## πŸ”§ Configuration + +The timer system requires no additional configuration and works automatically with: + +- Any `ScheduleWithPreparationEntity` that has a valid `scheduleTime` +- Both upcoming and ongoing schedule states +- All timezone-aware DateTime calculations + +## πŸ“Š Performance Considerations + +- **Single Timer**: Only one timer runs at a time per bloc instance +- **Minimal Memory Footprint**: Timers are created/destroyed as needed +- **Precise Timing**: Uses Dart's native Timer for accurate scheduling +- **Efficient Cleanup**: No lingering resources after bloc disposal + +## πŸš€ Future Enhancements + +Potential improvements to consider: + +- Multiple concurrent schedule timers +- Configurable timer precision (seconds vs milliseconds) +- Timer persistence across app restarts +- Integration with system-level scheduling APIs diff --git a/docs/Wiki-Management.md b/docs/Wiki-Management.md new file mode 100644 index 00000000..25dbcf12 --- /dev/null +++ b/docs/Wiki-Management.md @@ -0,0 +1,254 @@ +# Wiki Management Guide + +This guide explains how to modify, create, and upload wiki documentation for the OnTime Flutter project. + +## πŸ“– Overview + +Our project wiki is integrated directly into the main repository using Git subtrees. This means: + +- Wiki content is stored in the `docs/` folder +- Changes are version-controlled with the main codebase +- Documentation stays in sync with code changes +- New developers can access docs offline + +## πŸš€ Quick Start + +### Prerequisites + +- Git configured with your GitHub account +- Access to the OnTime-front repository +- Basic knowledge of Markdown + +### Current Wiki Structure + +``` +docs/ +β”œβ”€β”€ Architecture.md # Project structure and architecture +β”œβ”€β”€ Git.md # Git workflow and commit guidelines +β”œβ”€β”€ Home.md # Wiki homepage +└── Wiki-Management.md # This guide +``` + +## ✏️ Modifying Existing Wiki Pages + +### 1. Edit Files Locally + +Navigate to the `docs/` folder and edit any `.md` file: + +```bash +# Navigate to docs folder +cd docs/ + +# Edit existing files with your preferred editor +code Architecture.md +# or +vim Home.md +# or +nano Git.md +``` + +### 2. Preview Your Changes + +Use any Markdown preview tool or your IDE's built-in preview to review changes before committing. + +### 3. Commit Changes to Main Repository + +```bash +# From project root +git add docs/ +git commit -m "docs: update [filename] with [brief description]" +``` + +## πŸ“ Creating New Wiki Pages + +### 1. Create New Markdown File + +```bash +# From project root +touch docs/New-Page-Name.md +``` + +### 2. Add Content + +Use standard Markdown syntax. Here's a template: + +````markdown +# Page Title + +Brief description of what this page covers. + +## Section 1 + +Content here... + +### Subsection + +More detailed content... + +## Code Examples + +\```dart +// Flutter/Dart code examples +void main() { +print('Hello OnTime!'); +} +\``` + +## Links and References + +- [Internal Link](./Other-Page.md) +- [External Link](https://flutter.dev) +```` + +### 3. Update Navigation + +If creating a major new page, consider updating `Home.md` to include a link to your new page. + +## πŸ”„ Syncing with GitHub Wiki + +Our project uses Git subtree to keep the main repository and GitHub wiki synchronized. + +### Push Local Changes to GitHub Wiki + +After committing your documentation changes to the main repository: + +```bash +# Push documentation changes to GitHub wiki +git subtree push --prefix=docs wiki master +``` + +**What this does:** + +- Takes all changes from the `docs/` folder +- Pushes them to the GitHub wiki repository +- Updates the online wiki at `https://github.com/DevKor-github/OnTime-front/wiki` + +### Pull Changes from GitHub Wiki + +If someone edits the wiki directly on GitHub: + +```bash +# Pull changes from GitHub wiki to local docs folder +git subtree pull --prefix=docs wiki master --squash +``` + +**When to use this:** + +- Someone edited wiki pages directly on GitHub +- You want to sync external wiki changes to your local repository +- Before starting major documentation work (to avoid conflicts) + +## πŸ”§ Advanced Workflows + +### Working on Documentation-Heavy Features + +1. **Create a documentation branch:** + + ```bash + git checkout -b docs/feature-name + ``` + +2. **Make your documentation changes** + +3. **Commit and push to main repository:** + + ```bash + git add docs/ + git commit -m "docs: add documentation for feature-name" + git push origin docs/feature-name + ``` + +4. **Create PR for review** + +5. **After PR merge, sync to wiki:** + ```bash + git checkout main + git pull origin main + git subtree push --prefix=docs wiki master + ``` + +### Handling Merge Conflicts + +If you encounter conflicts when pulling from the wiki: + +1. **Resolve conflicts in the docs/ folder** +2. **Commit the resolution:** + ```bash + git add docs/ + git commit -m "docs: resolve wiki merge conflicts" + ``` +3. **Push resolved changes:** + ```bash + git subtree push --prefix=docs wiki master + ``` + +## πŸ“‹ Documentation Best Practices + +### File Naming Convention + +- Use kebab-case: `Getting-Started.md`, `API-Guide.md` +- Be descriptive but concise +- Avoid spaces and special characters + +### Content Guidelines + +1. **Start with a clear title and overview** +2. **Use consistent heading hierarchy (H1 β†’ H2 β†’ H3)** +3. **Include code examples where relevant** +4. **Add links to related documentation** +5. **Keep content up-to-date with code changes** + +### Markdown Tips + +- Use `backticks` for inline code +- Use triple backticks with language for code blocks +- Use `**bold**` for emphasis +- Use `> blockquotes` for important notes +- Create tables for structured data + +## πŸ› οΈ Troubleshooting + +### Common Issues + +**Issue: `fatal: working tree has modifications`** + +```bash +# Solution: Commit or stash changes first +git add . +git commit -m "docs: work in progress" +# or +git stash +``` + +**Issue: Wiki changes not appearing on GitHub** + +```bash +# Solution: Ensure you pushed to the wiki remote +git subtree push --prefix=docs wiki master +``` + +**Issue: Local docs out of sync** + +```bash +# Solution: Pull latest changes from wiki +git subtree pull --prefix=docs wiki master --squash +``` + +### Getting Help + +- Check Git status: `git status` +- View recent commits: `git log --oneline -10` +- Check remotes: `git remote -v` +- Ask team members or create an issue + +## 🎯 Recommended Documentation + +For new developers, consider creating these essential pages: + +- [ ] **Getting-Started.md** - Setup and installation guide +- [ ] **Development-Guide.md** - Development workflow and tools +- [ ] **API-Documentation.md** - Backend API reference +- [ ] **Testing-Guide.md** - How to run and write tests +- [ ] **Deployment.md** - Build and deployment procedures +- [ ] **Contributing.md** - Contribution guidelines +- [ ] **Troubleshooting.md** - Common issues and solutions diff --git a/lib/core/dio/interceptors/token_interceptor.dart b/lib/core/dio/interceptors/token_interceptor.dart index cd391270..c151ce69 100644 --- a/lib/core/dio/interceptors/token_interceptor.dart +++ b/lib/core/dio/interceptors/token_interceptor.dart @@ -1,11 +1,14 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; class TokenInterceptor implements InterceptorsWrapper { final Dio dio; TokenInterceptor(this.dio); - final TokenLocalDataSource tokenLocalDataSource = TokenLocalDataSourceImpl(); + final TokenLocalDataSource tokenLocalDataSource = + getIt.get(); // when accessToken is expired & having multiple requests call // this variable to lock others request to make sure only trigger call refresh token 01 times @@ -73,7 +76,12 @@ class TokenInterceptor implements InterceptorsWrapper { } else { _requestsNeedRetry.clear(); // if refresh fail, force logout user here - await tokenLocalDataSource.deleteToken(); + try { + await getIt.get().call(); + } catch (_) { + await tokenLocalDataSource.deleteToken(); + } + _isRefreshing = false; } } else { // if refresh flow is processing, add this request to queue and wait to retry later diff --git a/lib/data/repositories/user_repository_impl.dart b/lib/data/repositories/user_repository_impl.dart index b41571e7..5a721700 100644 --- a/lib/data/repositories/user_repository_impl.dart +++ b/lib/data/repositories/user_repository_impl.dart @@ -24,8 +24,8 @@ class UserRepositoryImpl implements UserRepository { @override GoogleSignIn get googleSignIn => _googleSignIn; - UserRepositoryImpl( - this._authenticationRemoteDataSource, this._tokenLocalDataSource); + UserRepositoryImpl(this._authenticationRemoteDataSource, this._tokenLocalDataSource); + @override Future getUser() async { @@ -81,7 +81,6 @@ class UserRepositoryImpl implements UserRepository { final signInWithGoogleRequestModel = SignInWithGoogleRequestModel( idToken: idToken, ); - print(idToken); await _tokenLocalDataSource.deleteToken(); final result = await _authenticationRemoteDataSource .signInWithGoogle(signInWithGoogleRequestModel); diff --git a/lib/presentation/alarm/bloc/alarm_timer/alarm_timer_bloc.dart b/lib/presentation/alarm/bloc/alarm_timer/alarm_timer_bloc.dart index f42ce827..864fd448 100644 --- a/lib/presentation/alarm/bloc/alarm_timer/alarm_timer_bloc.dart +++ b/lib/presentation/alarm/bloc/alarm_timer/alarm_timer_bloc.dart @@ -1,6 +1,7 @@ library; import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; @@ -117,7 +118,7 @@ class AlarmTimerBloc extends Bloc { _tickerSubscription?.cancel(); if (_stepStartTime == null || _beforeOutStartTime == null) { - print("_stepStartTime or _beforeOutStartTime is null"); + debugPrint("_stepStartTime or _beforeOutStartTime is null"); return; } @@ -136,7 +137,7 @@ class AlarmTimerBloc extends Bloc { final updatedIsLate = updatedBeforeOutTime <= 0; if (newRemaining >= 0) { - print("타이머 tick: $newRemaining초 λ‚¨μŒ"); + debugPrint("타이머 tick: $newRemaining초 λ‚¨μŒ"); add(AlarmTimerStepTicked( preparationRemainingTime: newRemaining, preparationElapsedTime: elapsed, @@ -145,7 +146,7 @@ class AlarmTimerBloc extends Bloc { isLate: updatedIsLate, )); } else { - print("타이머 μ™„λ£Œλ¨"); + debugPrint("타이머 μ™„λ£Œλ¨"); add(const AlarmTimerStepNextShifted()); } }); diff --git a/lib/presentation/app/bloc/app_bloc.dart b/lib/presentation/app/bloc/app_bloc.dart deleted file mode 100644 index 4ff99290..00000000 --- a/lib/presentation/app/bloc/app_bloc.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; - -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:injectable/injectable.dart'; -import 'package:on_time_front/core/services/navigation_service.dart'; -import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; -import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; -import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; - -part 'app_event.dart'; -part 'app_state.dart'; - -@Injectable() -class AppBloc extends Bloc { - AppBloc(this._streamUserUseCase, this._signOutUseCase, this._loadUserUseCase, - this._getNearestUpcomingScheduleUseCase, this._navigationService) - : super(AppState(user: const UserEntity.empty())) { - on(_appUserSubscriptionRequested); - on(_appLogoutPressed); - on( - _appUpcomingScheduleSubscriptionRequested, - ); - on( - _appUpcomingScheduleReceived, - ); - on( - _appPreparationStarted, - ); - } - - final StreamUserUseCase _streamUserUseCase; - final LoadUserUseCase _loadUserUseCase; - final SignOutUseCase _signOutUseCase; - final GetNearestUpcomingScheduleUseCase _getNearestUpcomingScheduleUseCase; - final NavigationService _navigationService; - Timer? _timer; - - Future _appUserSubscriptionRequested( - AppUserSubscriptionRequested event, - Emitter emit, - ) { - _loadUserUseCase(); - return emit.onEach( - _streamUserUseCase.call(), - onData: (user) async { - emit( - state.copyWith( - user: user, - status: user.map( - (entity) => entity.isOnboardingCompleted - ? AppStatus.authenticated - : AppStatus.onboardingNotCompleted, - empty: (_) => AppStatus.unauthenticated, - ), - ), - ); - await Future.delayed(const Duration(milliseconds: 0)); - if (state.status == AppStatus.authenticated) { - add(const AppUpcomingScheduleSubscriptionRequested()); - } - }, - onError: addError, - ); - } - - void _appLogoutPressed( - AppSignOutPressed event, - Emitter emit, - ) { - _signOutUseCase(); - } - - /// This method is called when the user is authenticated and the app is - /// waiting for the nearest upcoming schedule. - FutureOr _appUpcomingScheduleSubscriptionRequested( - AppUpcomingScheduleSubscriptionRequested event, - Emitter emit) async { - _getNearestUpcomingScheduleUseCase() - .listen((schedule) => add(AppUpcomingScheduleReceived(schedule))); - } - - /// This method is called when the nearest upcoming schedule is received. - void _appUpcomingScheduleReceived( - AppUpcomingScheduleReceived event, Emitter emit) { - final nearestUpcomingSchedule = event.nearestUpcomingSchedule; - - // If the app is in preparation started state, we only need to update the schedule. - if (state.status == AppStatus.preparationStarted) { - emit( - state.copyWith( - schedule: nearestUpcomingSchedule, - ), - ); - return; - } - - // If there is no upcoming schedule or the schedule is in the past, the app - if (nearestUpcomingSchedule == null || - nearestUpcomingSchedule.scheduleTime.isBefore(DateTime.now())) { - emit( - state.copyWith( - status: AppStatus.authenticated, - ), - ); - return; - } - - // If the preparation is ongoing, we need to update the state - if (_isPreparationOnGoing(nearestUpcomingSchedule)) { - emit( - state.copyWith( - status: AppStatus.preparationStarted, - schedule: nearestUpcomingSchedule, - ), - ); - return; - } - - // If the preparation is not ongoing, we need to set a timer for the preparation start time - final durationUntilSchedule = - nearestUpcomingSchedule.preparationStartTime.difference(DateTime.now()); - assert(!durationUntilSchedule.isNegative); - _timer?.cancel(); - _timer = Timer(durationUntilSchedule, () { - add(AppPreparationStarted(nearestUpcomingSchedule)); - }); - emit( - state.copyWith( - status: AppStatus.authenticated, - ), - ); - } - - bool _isPreparationOnGoing( - ScheduleWithPreparationEntity nearestUpcomingSchedule) { - return nearestUpcomingSchedule.preparationStartTime - .isBefore(DateTime.now()) && - nearestUpcomingSchedule.scheduleTime.isAfter(DateTime.now()); - } - - void _appPreparationStarted( - AppPreparationStarted event, Emitter emit) async { - _navigationService.push('/scheduleStart', extra: event.schedule); - emit( - state.copyWith( - status: AppStatus.preparationStarted, - schedule: event.schedule, - ), - ); - } -} diff --git a/lib/presentation/app/bloc/app_event.dart b/lib/presentation/app/bloc/app_event.dart deleted file mode 100644 index be5ac323..00000000 --- a/lib/presentation/app/bloc/app_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of 'app_bloc.dart'; - -abstract class AppEvent { - const AppEvent(); -} - -final class AppUserSubscriptionRequested extends AppEvent { - const AppUserSubscriptionRequested(); -} - -final class AppSignOutPressed extends AppEvent { - const AppSignOutPressed(); -} - -final class AppUpcomingScheduleSubscriptionRequested extends AppEvent { - const AppUpcomingScheduleSubscriptionRequested(); -} - -final class AppUpcomingScheduleReceived extends AppEvent { - final ScheduleWithPreparationEntity? nearestUpcomingSchedule; - - const AppUpcomingScheduleReceived(this.nearestUpcomingSchedule); -} - -final class AppPreparationStarted extends AppEvent { - final ScheduleWithPreparationEntity schedule; - - const AppPreparationStarted(this.schedule); -} diff --git a/lib/presentation/app/bloc/auth/auth_bloc.dart b/lib/presentation/app/bloc/auth/auth_bloc.dart new file mode 100644 index 00000000..4605f430 --- /dev/null +++ b/lib/presentation/app/bloc/auth/auth_bloc.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/sign_out_use_case.dart'; +import 'package:on_time_front/domain/use-cases/stream_user_use_case.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; + +part 'auth_event.dart'; +part 'auth_state.dart'; + +@Injectable() +class AuthBloc extends Bloc { + AuthBloc(this._streamUserUseCase, this._signOutUseCase, this._loadUserUseCase, + this._scheduleBloc) + : super(AuthState(user: const UserEntity.empty())) { + on(_appUserSubscriptionRequested); + on(_appLogoutPressed); + } + + final StreamUserUseCase _streamUserUseCase; + final LoadUserUseCase _loadUserUseCase; + final SignOutUseCase _signOutUseCase; + final ScheduleBloc _scheduleBloc; + Timer? _timer; + StreamSubscription? + _upcomingScheduleSubscription; + + Future _appUserSubscriptionRequested( + AuthUserSubscriptionRequested event, + Emitter emit, + ) { + _loadUserUseCase(); + return emit.onEach( + _streamUserUseCase.call(), + onData: (user) async { + emit( + state.copyWith( + user: user, + status: user.map( + (entity) => entity.isOnboardingCompleted + ? AuthStatus.authenticated + : AuthStatus.onboardingNotCompleted, + empty: (_) => AuthStatus.unauthenticated, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 0)); + if (state.status == AuthStatus.authenticated) { + _scheduleBloc.add(const ScheduleSubscriptionRequested()); + } + }, + onError: addError, + ); + } + + void _appLogoutPressed( + AuthSignOutPressed event, + Emitter emit, + ) { + _signOutUseCase(); + } + + @override + Future close() { + _timer?.cancel(); + _upcomingScheduleSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/presentation/app/bloc/auth/auth_event.dart b/lib/presentation/app/bloc/auth/auth_event.dart new file mode 100644 index 00000000..7f1fae10 --- /dev/null +++ b/lib/presentation/app/bloc/auth/auth_event.dart @@ -0,0 +1,13 @@ +part of 'auth_bloc.dart'; + +abstract class AuthEvent { + const AuthEvent(); +} + +final class AuthUserSubscriptionRequested extends AuthEvent { + const AuthUserSubscriptionRequested(); +} + +final class AuthSignOutPressed extends AuthEvent { + const AuthSignOutPressed(); +} diff --git a/lib/presentation/app/bloc/app_state.dart b/lib/presentation/app/bloc/auth/auth_state.dart similarity index 56% rename from lib/presentation/app/bloc/app_state.dart rename to lib/presentation/app/bloc/auth/auth_state.dart index a9afd340..98f3eebd 100644 --- a/lib/presentation/app/bloc/app_state.dart +++ b/lib/presentation/app/bloc/auth/auth_state.dart @@ -1,38 +1,37 @@ -part of 'app_bloc.dart'; +part of 'auth_bloc.dart'; -enum AppStatus { +enum AuthStatus { authenticated, unauthenticated, - preparationStarted, onboardingNotCompleted, } -class AppState extends Equatable { - AppState({UserEntity user = const UserEntity.empty()}) +class AuthState extends Equatable { + AuthState({UserEntity user = const UserEntity.empty()}) : this._( - status: user.map( + status: user.map( (entity) => entity.isOnboardingCompleted - ? AppStatus.unauthenticated - : AppStatus.onboardingNotCompleted, - empty: (_) => AppStatus.unauthenticated, + ? AuthStatus.unauthenticated + : AuthStatus.onboardingNotCompleted, + empty: (_) => AuthStatus.unauthenticated, ), user: user, ); - const AppState._( + const AuthState._( {required this.status, this.user = const UserEntity.empty(), this.schedule}); - final AppStatus status; + final AuthStatus status; final UserEntity user; final ScheduleWithPreparationEntity? schedule; - AppState copyWith( - {AppStatus? status, + AuthState copyWith( + {AuthStatus? status, UserEntity? user, ScheduleWithPreparationEntity? schedule}) { - return AppState._( + return AuthState._( status: status ?? this.status, user: user ?? this.user, schedule: schedule ?? this.schedule, diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart new file mode 100644 index 00000000..f1ed80c1 --- /dev/null +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/core/services/navigation_service.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; + +part 'schedule_event.dart'; +part 'schedule_state.dart'; + +@Singleton() +class ScheduleBloc extends Bloc { + ScheduleBloc(this._getNearestUpcomingScheduleUseCase, this._navigationService) + : super(const ScheduleState.initial()) { + on(_onSubscriptionRequested); + on(_onUpcomingReceived); + on(_onScheduleStarted); + } + + final GetNearestUpcomingScheduleUseCase _getNearestUpcomingScheduleUseCase; + final NavigationService _navigationService; + StreamSubscription? + _upcomingScheduleSubscription; + Timer? _scheduleStartTimer; + String? _currentScheduleId; + + Future _onSubscriptionRequested( + ScheduleSubscriptionRequested event, Emitter emit) async { + await _upcomingScheduleSubscription?.cancel(); + _upcomingScheduleSubscription = + _getNearestUpcomingScheduleUseCase().listen((upcomingSchedule) { + // βœ… Safety check: Only add events if bloc is still active + if (!isClosed) { + final scheduleWithTimePreparation = upcomingSchedule != null + ? _convertToScheduleWithTimePreparation(upcomingSchedule) + : null; + add(ScheduleUpcomingReceived(scheduleWithTimePreparation)); + } + }); + } + + Future _onUpcomingReceived( + ScheduleUpcomingReceived event, Emitter emit) async { + // Cancel any existing timer + _scheduleStartTimer?.cancel(); + _scheduleStartTimer = null; + if (event.upcomingSchedule == null || + event.upcomingSchedule!.scheduleTime.isBefore(DateTime.now())) { + emit(const ScheduleState.notExists()); + _currentScheduleId = null; + } else if (_isPreparationOnGoing(event.upcomingSchedule!)) { + final currentStep = _findCurrentPreparationStep( + event.upcomingSchedule!, + DateTime.now(), + ); + emit(ScheduleState.ongoing(event.upcomingSchedule!, currentStep)); + debugPrint( + 'ongoingSchedule: ${event.upcomingSchedule}, currentStep: $currentStep'); + } else { + emit(ScheduleState.upcoming(event.upcomingSchedule!)); + debugPrint('upcomingSchedule: ${event.upcomingSchedule}'); + _currentScheduleId = event.upcomingSchedule!.id; + _startScheduleTimer(event.upcomingSchedule!); + } + } + + Future _onScheduleStarted( + ScheduleStarted event, Emitter emit) async { + // Only process if this event is for the current schedule + if (state.schedule != null && state.schedule!.id == _currentScheduleId) { + // Mark the schedule as started by updating the state + debugPrint('scheddle started: ${state.schedule}'); + emit(ScheduleState.started(state.schedule!)); + _navigationService.push('/scheduleStart', extra: state.schedule); + } + } + + void _startScheduleTimer(ScheduleWithPreparationEntity schedule) { + final now = DateTime.now(); + final preparationStartTime = schedule.preparationStartTime; + + // If the target time is in the past or now, don't set a timer + if (preparationStartTime.isBefore(now) || + preparationStartTime.isAtSameMomentAs(now)) { + return; + } + + final duration = preparationStartTime.difference(now); + + debugPrint('duration: $duration'); + + _scheduleStartTimer = Timer(duration, () { + // Only add event if bloc is still active and schedule ID matches + if (!isClosed && _currentScheduleId == schedule.id) { + add(const ScheduleStarted()); + } + }); + } + + @override + Future close() { + // βœ… Proper cleanup: Cancel subscription and timer before closing + _upcomingScheduleSubscription?.cancel(); + _scheduleStartTimer?.cancel(); + return super.close(); + } + + bool _isPreparationOnGoing( + ScheduleWithPreparationEntity nearestUpcomingSchedule) { + return nearestUpcomingSchedule.preparationStartTime + .isBefore(DateTime.now()) && + nearestUpcomingSchedule.scheduleTime.isAfter(DateTime.now()); + } + + PreparationStepEntity _findCurrentPreparationStep( + ScheduleWithPreparationEntity schedule, DateTime now) { + final List steps = schedule + .preparation.preparationStepList + .cast(); + + if (steps.isEmpty) { + throw StateError('Preparation steps are empty'); + } + + final DateTime preparationStartTime = schedule.preparationStartTime; + + // If called when not in preparation window, clamp to bounds + if (now.isBefore(preparationStartTime)) { + return steps.first; + } + + Duration elapsed = now.difference(preparationStartTime); + + for (final PreparationStepWithTime step in steps) { + if (elapsed < step.preparationTime) { + return step.copyWithElapsed(elapsed); + } + elapsed -= step.preparationTime; + } + + // If elapsed exceeds total preparation duration (e.g., during move/spare time), + // return the last preparation step as current by convention. + return steps.last.copyWithElapsed(steps.last.preparationTime); + } + + ScheduleWithPreparationEntity _convertToScheduleWithTimePreparation( + ScheduleWithPreparationEntity schedule) { + final preparationWithTime = PreparationWithTime( + preparationStepList: schedule.preparation.preparationStepList + .map((step) => PreparationStepWithTime( + id: step.id, + preparationName: step.preparationName, + preparationTime: step.preparationTime, + nextPreparationId: step.nextPreparationId, + )) + .toList(), + ); + + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + preparationWithTime, + ); + } +} diff --git a/lib/presentation/app/bloc/schedule/schedule_event.dart b/lib/presentation/app/bloc/schedule/schedule_event.dart new file mode 100644 index 00000000..98544f5b --- /dev/null +++ b/lib/presentation/app/bloc/schedule/schedule_event.dart @@ -0,0 +1,38 @@ +part of 'schedule_bloc.dart'; + +class ScheduleEvent extends Equatable { + const ScheduleEvent(); + + @override + List get props => []; +} + +final class ScheduleSubscriptionRequested extends ScheduleEvent { + const ScheduleSubscriptionRequested(); + + @override + List get props => []; +} + +final class ScheduleUpcomingReceived extends ScheduleEvent { + final ScheduleWithPreparationEntity? upcomingSchedule; + + const ScheduleUpcomingReceived(this.upcomingSchedule); + + @override + List get props => [upcomingSchedule]; +} + +final class ScheduleStarted extends ScheduleEvent { + const ScheduleStarted(); + + @override + List get props => []; +} + +final class SchedulePreparationStarted extends ScheduleEvent { + const SchedulePreparationStarted(); + + @override + List get props => []; +} diff --git a/lib/presentation/app/bloc/schedule/schedule_state.dart b/lib/presentation/app/bloc/schedule/schedule_state.dart new file mode 100644 index 00000000..c9259d74 --- /dev/null +++ b/lib/presentation/app/bloc/schedule/schedule_state.dart @@ -0,0 +1,93 @@ +part of 'schedule_bloc.dart'; + +enum ScheduleStatus { + initial, + notExists, + upcoming, + ongoing, + started, +} + +class ScheduleState extends Equatable { + const ScheduleState._({ + required this.status, + this.schedule, + this.currentStep, + }); + + const ScheduleState.initial() : this._(status: ScheduleStatus.initial); + + const ScheduleState.notExists() : this._(status: ScheduleStatus.notExists); + + const ScheduleState.upcoming(ScheduleWithPreparationEntity schedule) + : this._(status: ScheduleStatus.upcoming, schedule: schedule); + + const ScheduleState.ongoing( + ScheduleWithPreparationEntity schedule, PreparationStepEntity currentStep) + : this._( + status: ScheduleStatus.ongoing, + schedule: schedule, + currentStep: currentStep); + + const ScheduleState.started(ScheduleWithPreparationEntity schedule) + : this._(status: ScheduleStatus.started, schedule: schedule); + + final ScheduleStatus status; + final ScheduleWithPreparationEntity? schedule; + final PreparationStepEntity? currentStep; + + ScheduleState copyWith({ + ScheduleStatus? status, + ScheduleWithPreparationEntity? schedule, + PreparationStepEntity? currentStep, + }) { + return ScheduleState._( + status: status ?? this.status, + schedule: schedule ?? this.schedule, + currentStep: currentStep ?? this.currentStep, + ); + } + + @override + List get props => [status, schedule]; +} + +class PreparationStepWithTime extends PreparationStepEntity + implements Equatable { + final Duration elapsedTime; + + const PreparationStepWithTime({ + required super.id, + required super.preparationName, + required super.preparationTime, + required super.nextPreparationId, + this.elapsedTime = Duration.zero, + }); + + PreparationStepWithTime copyWithElapsed(Duration elapsed) { + return PreparationStepWithTime( + id: id, + preparationName: preparationName, + preparationTime: preparationTime, + nextPreparationId: nextPreparationId, + elapsedTime: elapsed, + ); + } + + @override + List get props => + [id, preparationName, preparationTime, nextPreparationId, elapsedTime]; +} + +class PreparationWithTime extends PreparationEntity implements Equatable { + const PreparationWithTime({ + required List preparationStepList, + }) : super(preparationStepList: preparationStepList); + + @override + List get preparationStepList => + super.preparationStepList.cast(); + + @override + List get props => [preparationStepList]; +} diff --git a/lib/presentation/app/screens/app.dart b/lib/presentation/app/screens/app.dart index 4af5fd32..9a6c3cc1 100644 --- a/lib/presentation/app/screens/app.dart +++ b/lib/presentation/app/screens/app.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; import 'package:on_time_front/presentation/shared/router/go_router.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; @@ -11,9 +12,16 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - getIt.get()..add(const AppUserSubscriptionRequested()), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + getIt.get()..add(const AuthUserSubscriptionRequested()), + ), + BlocProvider( + create: (context) => getIt.get(), + ), + ], child: const AppView(), ); } @@ -26,7 +34,8 @@ class AppView extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp.router( theme: themeData, - routerConfig: goRouterConfig(context.read()), + routerConfig: goRouterConfig( + context.read(), context.read()), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, ); diff --git a/lib/presentation/home/components/home_app_bar.dart b/lib/presentation/home/components/home_app_bar.dart index a17bb8ba..e90ecbf9 100644 --- a/lib/presentation/home/components/home_app_bar.dart +++ b/lib/presentation/home/components/home_app_bar.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/shared/constants/constants.dart'; class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -35,7 +35,7 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { IconButton( icon: friendsSvg, onPressed: () { - context.read().add(AppSignOutPressed()); + context.read().add(AuthSignOutPressed()); }, ), IconButton( diff --git a/lib/presentation/home/screens/home_screen.dart b/lib/presentation/home/screens/home_screen.dart index 5ada6aec..2d8f882d 100644 --- a/lib/presentation/home/screens/home_screen.dart +++ b/lib/presentation/home/screens/home_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/home/bloc/weekly_schedules_bloc.dart'; import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; import 'package:on_time_front/presentation/home/components/week_calendar.dart'; @@ -28,7 +28,7 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { final dateOfToday = DateTime( DateTime.now().year, DateTime.now().month, DateTime.now().day, 0, 0, 0); - final double score = context.select((AppBloc bloc) => + final double score = context.select((AuthBloc bloc) => bloc.state.user.mapOrNull((user) => user.score) ?? -1); return BlocProvider( diff --git a/lib/presentation/home/screens/home_screen_tmp.dart b/lib/presentation/home/screens/home_screen_tmp.dart index 69cbecd8..5719c825 100644 --- a/lib/presentation/home/screens/home_screen_tmp.dart +++ b/lib/presentation/home/screens/home_screen_tmp.dart @@ -3,7 +3,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/calendar/bloc/monthly_schedules_bloc.dart'; import 'package:on_time_front/presentation/home/components/todays_schedule_tile.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -46,11 +46,12 @@ class HomeScreenContent extends StatelessWidget { @override Widget build(BuildContext context) { final double score = userScore ?? - context.select((AppBloc bloc) => + context.select((AuthBloc bloc) => bloc.state.user.mapOrNull((user) => user.score) ?? -1); final colorScheme = Theme.of(context).colorScheme; return SingleChildScrollView( + physics: const ClampingScrollPhysics(), child: Column( mainAxisSize: MainAxisSize.max, children: [ diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 8927c150..f510ab6b 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; class MyPageScreen extends StatelessWidget { const MyPageScreen({super.key}); @@ -56,9 +56,9 @@ class _MyAccountView extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return BlocBuilder( + return BlocBuilder( builder: (context, state) { - if (state.status == AppStatus.authenticated) { + if (state.status == AuthStatus.authenticated) { final user = state.user.mapOrNull( (user) => user, empty: (_) => null, diff --git a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart index 958f09fa..5ab142bf 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; @@ -17,7 +17,7 @@ class PreparationSpareTimeEditScreen extends StatelessWidget { return BlocProvider( create: (context) { final spareTime = context - .read() + .read() .state .user .mapOrNull((user) => user.spareTime) ?? diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart index c72b4c15..51e9de12 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/screens/schedule_spare_and_preparing_time_form.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/cubit/schedule_form_spare_time_cubit.dart'; import 'package:on_time_front/presentation/shared/components/cupertino_picker_modal.dart'; @@ -62,7 +62,7 @@ class _ScheduleSpareAndPreparingTimeFormState builder: (context, spareTimeState) { final Duration spareTime = spareTimeState.spareTime.value ?? spareTimeState.spareTime.value ?? - context.select((AppBloc appBloc) => + context.select((AuthBloc appBloc) => appBloc.state.user.mapOrNull((user) => user.spareTime))!; return Expanded( flex: 1, diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 7ab56935..75e7d47c 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -8,8 +8,9 @@ import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/presentation/alarm/screens/alarm_screen.dart'; import 'package:on_time_front/presentation/alarm/screens/schedule_start_screen.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; import 'package:on_time_front/presentation/early_late/screens/early_late_screen.dart'; -import 'package:on_time_front/presentation/app/bloc/app_bloc.dart'; import 'package:on_time_front/presentation/calendar/screens/calendar_screen.dart'; import 'package:on_time_front/presentation/home/screens/home_screen_tmp.dart'; import 'package:on_time_front/presentation/login/screens/sign_in_main_screen.dart'; @@ -27,21 +28,23 @@ import 'package:on_time_front/presentation/shared/utils/stream_to_listenable.dar final GlobalKey navigatorKey = GlobalKey(); -GoRouter goRouterConfig(AppBloc bloc) { +GoRouter goRouterConfig(AuthBloc authBloc, ScheduleBloc scheduleBloc) { return GoRouter( - refreshListenable: StreamToListenable([bloc.stream]), + refreshListenable: + StreamToListenable([scheduleBloc.stream, authBloc.stream]), navigatorKey: getIt.get().navigatorKey, redirect: (BuildContext context, GoRouterState state) async { - final status = bloc.state.status; + final authStatus = authBloc.state.status; + final scheduleStatus = scheduleBloc.state.status; final bool onSignInScreen = state.fullPath == '/signIn'; final bool onOnbaordingStartScreen = state.fullPath == '/onboarding/start'; final bool onOnboardingScreen = state.fullPath == '/onboarding'; - switch (status) { - case AppStatus.unauthenticated: + switch (authStatus) { + case AuthStatus.unauthenticated: return '/signIn'; - case AppStatus.authenticated: + case AuthStatus.authenticated: if (onSignInScreen || onOnboardingScreen || onOnbaordingStartScreen) { final permission = await NotificationService.instance .checkNotificationPermission(); @@ -49,17 +52,17 @@ GoRouter goRouterConfig(AppBloc bloc) { return '/allowNotification'; } return '/home'; + } else if (scheduleStatus == ScheduleStatus.started) { + return null; } else { return null; } - case AppStatus.onboardingNotCompleted: + case AuthStatus.onboardingNotCompleted: if (onOnboardingScreen || onOnbaordingStartScreen) { return null; } else { return '/onboarding/start'; } - case AppStatus.preparationStarted: - return null; } }, initialLocation: '/home',