Rehabilitation Platform project
Rehabilitation Mobile App
Work

Rehabilitation Mobile App

Sep 2022
Table of Contents

Overview

This Mobile App is a B2B2C application designed to support patients in their rehabilitation journey.

It provides personalized training programs, real-time motion analysis using AI, and gamification features to maintain user motivation.

The app connects with the Backend Platform to sync training data and allow therapists to monitor patient progress.

Tech Stack

TechnologyCategoryDescription
FlutterFrameworkCross-platform development for iOS and Android with a single codebase.
RiverpodState ManagementA reactive caching and data-binding framework. Used hooks_riverpod for cleaner widget integration.
TensorFlow LiteAI / MLOn-device machine learning for real-time pose detection and motion analysis.
FirebaseBackend / InfraAuth, Firestore, Analytics, Crashlytics, and Remote Config.
DioNetworkingPowerful HTTP client for API communication.
FreezedCode GenerationImmutable data classes and unions for robust type safety.
AutoRouteRoutingStrongly-typed routing solution.

Architecture: MVVM + BLoC + Repository Pattern

We adopted the MVVM (Model-View-ViewModel) pattern combined with the BLoC (Business Logic Component) pattern and the Repository Pattern to ensure separation of concerns and testability.

Directory Structure

lib/
├── views/
│   ├── screens/     # Screens (Pages)
│   └── widgets/     # Reusable Widgets. Container Components (logic/state) and Presentation Components (UI/rendering) are here.
├── view_models/     # State Management (Riverpod Notifiers)
├── data/
│   ├── repositories/ # Repository Interfaces & Implementations
│   ├── models/       # Data Models (Freezed)
│   ├── remote/       # API Clients (Dio)
│   └── local/        # Local Storage (SharedPreferences, SQLite)
└── pose_analyzer/   # Core AI Logic

Mobile Architecture

Key Technical Components

1. Component Design Pattern

We strictly separate Container Components (logic/state) from Presentation Components (UI/rendering).

Container Component (C)

Handles business logic, connects to Riverpod providers, and passes data to the Presentation layer.

class CTrainingList extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. Connect to State (ViewModel)
    final trainingListNotifier = ref.watch(trainingListProvider);
    final trainingSetNotifier = ref.watch(trainingSetProvider);

    return Column(
      children: [
        // 2. Pass state to Presentation Components
        PTrainingListview(
          trainingSet: AppConfig.trainingSetDailyTemp,
          // 3. Define behavior (Callbacks)
          onTapCardItem: (training) async {
             // Handle navigation or logic here
             trainingListNotifier.handleTap(training);
          },
        ),
      ],
    );
  }
}lib/views/widgets/.../c_training_list.dart

Presentation Component (P)

Pure UI component that receives data via constructor and renders it. It does not know about Riverpod or business logic.

class PTrainingInfoCard extends StatelessWidget {
  const PTrainingInfoCard({
    Key? key,
    required this.training,
    required this.onTapItem,
    this.isFavorite = false,
  }) : super(key: key);

  final Training training;
  final Function()? onTapItem;
  final bool isFavorite;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTapItem, // Execute callback passed from Container
      child: Column(
        children: [
          Image.network(training.thumbnailImageUrl),
          Text(training.name),
          if (isFavorite) Icon(Icons.favorite),
        ],
      ),
    );
  }
}lib/views/.../p_training_info_card.dart

2. State Management with Riverpod

Managing the complex state of a training session (timer, video playback, recording, AI feedback) required a robust solution. We chose Riverpod over other solutions (like Provider or GetX) because it offers:

For example, the TrainingListNotifier handles the fetching and caching of training sets, favorite status, and category filtering.

class TrainingListNotifier extends ChangeNotifier {
  // ...
  AsyncValue<TrainingSetUpdate> trainingListState = const AsyncValue.loading();

  Future<void> getFavoriteList() async {
    try {
      final userId = await PreferenceKey.userId.getString();
      // Repository call
      final result = (await _trainingRepo.getFavoriteList(
        userId: userId,
        isExcludeTrainingSet: false,
      )).dataOrThrow;

      // Update State
      trainingListState = AsyncValue.data(result);
    } catch (e) {
      trainingListState = AsyncValue.error(e);
    }
    notifyListeners();
  }
}lib/.../training_list_notifier.dart

In the UI (Container Component), we watch this state and rebuild when it changes:

class CTrainingList extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Watch the notifier state
    final trainingListState = ref.watch(trainingListProvider).trainingListState;

    return trainingListState.when(
      data: (data) => PTrainingList(data: data),
      loading: () => const CircularProgressIndicator(),
      error: (err, _) => ContainerErrorHandling(
        error: err,
        onRetry: () => ref.refresh(trainingListProvider),
      ),
    );
  }
}

3. Type-Safe Data Models with Freezed

We used Freezed to generate immutable data classes, ensuring type safety and reducing boilerplate code. This was particularly useful for handling complex JSON responses from the API.

@Freezed(makeCollectionsUnmodifiable: false)
abstract class Training with _$Training {
  const Training._();

  factory Training({
    @JsonKey(name: "id") @Default(0) int id,
    @JsonKey(name: "name") @Default("") String name,
    @JsonKey(name: "video_url") @Default("") String videoUrl,
    @JsonKey(name: "is_favorite") @Default(false) bool isFavorite,
    // ... other fields
  }) = _Training;

  factory Training.fromJson(Map<String, dynamic> json) =>
      _$TrainingFromJson(json);
}lib/data/models/training/training.dart

Then to generate the code, we run:

flutter pub run build_runner build --delete-conflicting-outputs

We can easily create copies with modified values using copyWith, which is essential for immutable state updates in Notifiers:

void toggleFavorite(Training training) {
  // 1. Create a copy with the new value
  final updatedTraining = training.copyWith(
    isFavorite: !training.isFavorite
  );

  // 2. Update the state (assuming state is a list of trainings)
  state = [
    for (final t in state)
      if (t.id == training.id) updatedTraining else t
  ];
}lib/.../training_list_notifier.dart

4. Repository Pattern for Data Abstraction

The Repository Pattern abstracts the data source (API, local DB) from the business logic. This makes the code more testable and allows us to easily switch data sources if needed.

abstract class TrainingSetRepository {
  Future<Result<TrainingSet>> getTrainingSet({
    required String userId,
    required int userObjectiveId,
  });
  // ...
}lib/data/.../training_set_repository.dart

The implementation handles the actual data fetching (e.g., from an API client):

class TrainingSetRepositoryImpl implements TrainingSetRepository {
  TrainingSetRepositoryImpl(this._read);

  final Reader _read;
  late final _api = _read(apolloApiClientProvider);

  @override
  Future<Result<TrainingSet>> getTrainingSet({
    required String userId,
    required int userObjectiveId,
  }) async {
    return await _api.getTrainingSet(
      userId: userId,
      userObjectiveId: userObjectiveId
    );
  }
}lib/data/.../training_set_repository_impl.dart

This repository is then injected into the ViewModel (Notifier) via Riverpod:

class TrainingListNotifier extends ChangeNotifier {
  TrainingListNotifier(this._read);
  final Reader _read;

  // Dependency Injection
  late final _trainingRepo = _read(trainingSetRepositoryProvider);

  Future<void> loadData() async {
    final result = await _trainingRepo.getTrainingSet(...);
    // ...
  }
}lib/.../training_list_notifier.dart

5. Real-time AI Motion Analysis

One of the core features is the ability to analyze the user’s posture during training in real-time. We used TensorFlow Lite (PoseNet/MoveNet) to detect body keypoints and implemented custom logic to validate correctness.

The SagittalPlaneAnalyzer calculates the relative positions of keypoints (e.g., nose, ankles, shoulders) to determine if the user is in the correct position relative to the camera.

class SagittalPlaneAnalyzer extends PoseAnalyzer {
  // ...
  @override
  PoseAnalyzeResult analyze({
    required Size previewSize,
    required Map<int, dynamic> keyPoints,
  }) {
    // Determine if the position is correct based on multiple criteria
    final bool isCorrect = _isNoseYPositionCorrect(
          keyPoints: keyPoints,
          previewSize: previewSize,
        ) &&
        _isAnkleYPositionCorrect(
          keyPoints: keyPoints,
          previewSize: previewSize,
        ) &&
        _isNeckXPositionCorrect(keyPoints: keyPoints, previewSize: previewSize);

    return PoseAnalyzeResult(isCorrect: isCorrect, howToFixText: _howToFixText);
  }

  // Example: Check if the nose is within the valid Y-axis range
  bool _isNoseYPositionCorrect({
    required Map<int, dynamic> keyPoints,
    required Size previewSize,
  }) {
    final double noseY =
        keyPoints[BodyKeyPoint.nose.index]["y"] * previewSize.height;
    final double invalidAreaHeight = previewSize.height / 10;

    return invalidAreaHeight < noseY;
  }
}lib/pose_analyzer/sagittal_plane_analyzer.dart

This logic runs on every frame processed by the camera, providing immediate feedback to the user (e.g., “Please move further away”).

Related Projects

    Mike 3.0

    Send a message to start the chat!

    You can ask the bot anything about me and it will help to find the relevant information!

    Try asking: