**Prepared by:** Oditha

**Date:** 23 January 2026

**Version:** 1.0

**Reference:** [FRD - HoReCa Field Operations & Workflow Management](https://www.notion.so/FRD-HoReCa-Field-Operations-Workflow-Management-2f7cd2773bc548c9a2595d856f15ac15?pvs=21)

---

## Overview

This document provides detailed technical specifications for implementing the **Mobile Application** of the Lion HoReCa Excellence system using **Flutter**.

---

## Technology Stack

### Mobile Framework

-   **Flutter:** 3.16+
-   **Dart:** 3.2+
-   **Minimum SDK:** Android 21 (Lollipop) / iOS 12.0

### State Management

-   **Provider** or **Riverpod** for dependency injection and state management
-   **BLoC pattern** for complex business logic

### Local Storage

-   **Sqflite:** SQLite database for offline data
-   **Shared Preferences:** Simple key-value storage
-   **Flutter Secure Storage:** Sensitive data (tokens)

### Network & API

-   **Dio:** HTTP client with interceptors
-   **Retrofit/Generated API:** Type-safe API calls

### Other Packages

-   **geolocator:** Location services
-   **permission_handler:** Runtime permissions
-   **image_picker / camera:** Photo capture
-   **cached_network_image:** Image caching
-   **fl_chart:** Charts and visualizations
-   **firebase_messaging:** Push notifications

---

## Project Structure

```dart
lib/
├── main.dart
├── app.dart
├── core/
│   ├── config/
│   │   ├── app_config.dart
│   │   └── routes.dart
│   ├── constants/
│   │   ├── api_constants.dart
│   │   ├── colors.dart
│   │   └── strings.dart
│   ├── utils/
│   │   ├── date_utils.dart
│   │   ├── validators.dart
│   │   └── helpers.dart
│   └── errors/
│       ├── exceptions.dart
│       └── failures.dart
├── data/
│   ├── datasources/
│   │   ├── local/
│   │   │   ├── database_helper.dart
│   │   │   ├── outlet_local_datasource.dart
│   │   │   ├── workflow_local_datasource.dart
│   │   │   └── sync_queue_datasource.dart
│   │   └── remote/
│   │       ├── auth_remote_datasource.dart
│   │       ├── outlet_remote_datasource.dart
│   │       ├── workflow_remote_datasource.dart
│   │       ├── service_request_remote_datasource.dart
│   │       └── sync_remote_datasource.dart
│   ├── models/
│   │   ├── user_model.dart
│   │   ├── outlet_model.dart
│   │   ├── workflow_model.dart
│   │   ├── workflow_step_model.dart
│   │   ├── execution_model.dart
│   │   ├── service_request_model.dart
│   │   └── audit_model.dart
│   └── repositories/
│       ├── auth_repository.dart
│       ├── outlet_repository.dart
│       ├── workflow_repository.dart
│       ├── service_request_repository.dart
│       └── sync_repository.dart
├── domain/
│   ├── entities/
│   │   ├── user.dart
│   │   ├── outlet.dart
│   │   ├── workflow.dart
│   │   └── service_request.dart
│   └── usecases/
│       ├── auth/
│       │   ├── login_usecase.dart
│       │   └── logout_usecase.dart
│       ├── outlet/
│       │   ├── get_outlets_usecase.dart
│       │   └── check_in_usecase.dart
│       ├── workflow/
│       │   ├── execute_workflow_usecase.dart
│       │   └── submit_step_usecase.dart
│       └── sync/
│           └── sync_offline_data_usecase.dart
└── presentation/
    ├── blocs/ (or providers/)
    │   ├── auth/
    │   │   ├── auth_bloc.dart
    │   │   ├── auth_event.dart
    │   │   └── auth_state.dart
    │   ├── outlet/
    │   ├── workflow/
    │   └── sync/
    ├── pages/
    │   ├── auth/
    │   │   ├── login_page.dart
    │   │   └── splash_page.dart
    │   ├── home/
    │   │   └── home_page.dart
    │   ├── outlet/
    │   │   ├── outlets_list_page.dart
    │   │   ├── outlet_detail_page.dart
    │   │   └── check_in_page.dart
    │   ├── workflow/
    │   │   └── workflow_execution_page.dart
    │   ├── audit/
    │   │   └── audit_execution_page.dart
    │   ├── service_request/
    │   │   ├── service_requests_page.dart
    │   │   └── service_request_detail_page.dart
    │   └── profile/
    │       └── profile_page.dart
    └── widgets/
        ├── common/
        │   ├── app_button.dart
        │   ├── app_text_field.dart
        │   ├── loading_widget.dart
        │   └── error_widget.dart
        ├── outlet/
        │   ├── outlet_card.dart
        │   └── outlet_status_badge.dart
        └── workflow/
            ├── workflow_step_widget.dart
            └── step_progress_indicator.dart
```

---

## Core Implementation

### 1. Database Setup

**database_helper.dart:**

```dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('horeca_local.db');
    return _database!;
  }

  Future<Database> _initDB(String filePath) async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, filePath);

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDB,
      onUpgrade: _upgradeDB,
    );
  }

  Future _createDB(Database db, int version) async {
    const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT';
    const textType = 'TEXT NOT NULL';
    const intType = 'INTEGER NOT NULL';
    const realType = 'REAL NOT NULL';
    const boolType = 'INTEGER NOT NULL';

    // Outlets table
    await db.execute('''
      CREATE TABLE outlets (
        id $idType,
        name $textType,
        address $textType,
        latitude $realType,
        longitude $realType,
        outlet_type $textType,
        workflow_template_id $intType,
        last_synced_at TEXT,
        data TEXT
      )
    ''');

    // Workflows table
    await db.execute('''
      CREATE TABLE workflows (
        id $idType,
        name $textType,
        category $textType,
        steps TEXT,
        last_synced_at TEXT
      )
    ''');

    // Workflow executions table
    await db.execute('''
      CREATE TABLE workflow_executions (
        id $idType,
        temp_id TEXT,
        workflow_id $intType,
        outlet_id $intType,
        visit_id $intType,
        status $textType,
        responses TEXT,
        started_at TEXT,
        completed_at TEXT,
        is_synced $boolType
      )
    ''');

    // Visits table
    await db.execute('''
      CREATE TABLE visits (
        id $idType,
        temp_id TEXT,
        outlet_id $intType,
        check_in_time TEXT,
        check_in_lat $realType,
        check_in_lng $realType,
        check_out_time TEXT,
        visit_outcome $textType,
        is_synced $boolType
      )
    ''');

    // Sync queue table
    await db.execute('''
      CREATE TABLE sync_queue (
        id $idType,
        entity_type $textType,
        entity_id TEXT,
        action $textType,
        data TEXT,
        priority $intType,
        created_at TEXT,
        retry_count $intType
      )
    ''');
  }

  Future _upgradeDB(Database db, int oldVersion, int newVersion) async {
    // Handle database migrations
  }

  Future close() async {
    final db = await instance.database;
    db.close();
  }
}
```

---

### 2. API Client Setup

**api_client.dart:**

```dart
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class ApiClient {
  late Dio _dio;
  final FlutterSecureStorage _storage = const FlutterSecureStorage();

  ApiClient() {
    _dio = Dio(
      BaseOptions(
        baseUrl: 'https://api.lionhoreca.com/v1',
        connectTimeout: const Duration(seconds: 30),
        receiveTimeout: const Duration(seconds: 30),
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      ),
    );

    // Add interceptors
    _dio.interceptors.add(AuthInterceptor(_storage));
    _dio.interceptors.add(LogInterceptor(
      requestBody: true,
      responseBody: true,
    ));
  }

  Dio get dio => _dio;
}

class AuthInterceptor extends Interceptor {
  final FlutterSecureStorage storage;

  AuthInterceptor(this.storage);

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await storage.read(key: 'auth_token');
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      // Token expired, redirect to login
      await storage.delete(key: 'auth_token');
      // Navigate to login page
    }
    handler.next(err);
  }
}
```

---

### 3. Offline Data Management

**sync_repository.dart:**

```dart
import 'dart:convert';
import 'package:sqflite/sqflite.dart';

class SyncRepository {
  final Database _database;
  final ApiClient _apiClient;

  SyncRepository(this._database, this._apiClient);

  Future<void> syncOfflineData() async {
    // Get all pending sync items ordered by priority
    final List<Map<String, dynamic>> syncQueue = await _database.query(
      'sync_queue',
      orderBy: 'priority DESC, created_at ASC',
    );

    for (var item in syncQueue) {
      try {
        await _syncItem(item);

        // Remove from queue on success
        await _database.delete(
          'sync_queue',
          where: 'id = ?',
          whereArgs: [item['id']],
        );
      } catch (e) {
        // Increment retry count
        await _database.update(
          'sync_queue',
          {'retry_count': item['retry_count'] + 1},
          where: 'id = ?',
          whereArgs: [item['id']],
        );

        // If retry count exceeds limit, mark as failed
        if (item['retry_count'] >= 3) {
          // Log error or notify user
        }
      }
    }
  }

  Future<void> _syncItem(Map<String, dynamic> item) async {
    final entityType = item['entity_type'];
    final data = jsonDecode(item['data']);

    switch (entityType) {
      case 'visit':
        await _syncVisit(data);
        break;
      case 'workflow_execution':
        await _syncWorkflowExecution(data);
        break;
      case 'service_request':
        await _syncServiceRequest(data);
        break;
    }
  }

  Future<void> _syncVisit(Map<String, dynamic> data) async {
    final response = await _apiClient.dio.post(
      '/mobile/outlets/${data['outlet_id']}/check-in',
      data: data,
    );

    // Update local record with server ID
    await _database.update(
      'visits',
      {
        'id': response.data['data']['visit']['id'],
        'is_synced': 1,
      },
      where: 'temp_id = ?',
      whereArgs: [data['temp_id']],
    );
  }

  Future<void> _syncWorkflowExecution(Map<String, dynamic> data) async {
    // Similar implementation for workflow executions
  }

  Future<void> _syncServiceRequest(Map<String, dynamic> data) async {
    // Similar implementation for service requests
  }

  Future<void> addToSyncQueue({
    required String entityType,
    required String entityId,
    required String action,
    required Map<String, dynamic> data,
    int priority = 5,
  }) async {
    await _database.insert('sync_queue', {
      'entity_type': entityType,
      'entity_id': entityId,
      'action': action,
      'data': jsonEncode(data),
      'priority': priority,
      'created_at': DateTime.now().toIso8601String(),
      'retry_count': 0,
    });
  }
}
```

---

### 4. Workflow Execution Screen

**workflow_execution_page.dart:**

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class WorkflowExecutionPage extends StatefulWidget {
  final int workflowId;
  final int outletId;
  final int visitId;

  const WorkflowExecutionPage({
    Key? key,
    required this.workflowId,
    required this.outletId,
    required this.visitId,
  }) : super(key: key);

  @override
  State<WorkflowExecutionPage> createState() => _WorkflowExecutionPageState();
}

class _WorkflowExecutionPageState extends State<WorkflowExecutionPage> {
  int currentStepIndex = 0;
  Map<int, dynamic> responses = {};

  @override
  void initState() {
    super.initState();
    _loadWorkflow();
  }

  Future<void> _loadWorkflow() async {
    final workflowBloc = context.read<WorkflowBloc>();
    workflowBloc.add(LoadWorkflowEvent(
      workflowId: widget.workflowId,
      outletId: widget.outletId,
    ));
  }

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<WorkflowBloc, WorkflowState>(
      listener: (context, state) {
        if (state is WorkflowCompletedState) {
          _showCompletionDialog();
        } else if (state is ServiceRequestTriggeredState) {
          _showServiceRequestDialog(state.serviceRequest);
        }
      },
      builder: (context, state) {
        if (state is WorkflowLoadingState) {
          return const Center(child: CircularProgressIndicator());
        }

        if (state is WorkflowLoadedState) {
          final workflow = state.workflow;
          final currentStep = _getCurrentVisibleStep(workflow);

          return Scaffold(
            appBar: AppBar(
              title: Text(workflow.name),
              actions: [
                IconButton(
                  icon: const Icon(Icons.save),
                  onPressed: _saveDraft,
                ),
              ],
            ),
            body: Column(
              children: [
                _buildProgressIndicator(workflow),
                Expanded(
                  child: _buildStepContent(currentStep),
                ),
                _buildNavigationButtons(currentStep),
              ],
            ),
          );
        }

        return const Center(child: Text('Error loading workflow'));
      },
    );
  }

  Widget _buildProgressIndicator(Workflow workflow) {
    final visibleSteps = _getVisibleSteps(workflow);
    final progress = currentStepIndex / visibleSteps.length;

    return Container(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          LinearProgressIndicator(value: progress),
          const SizedBox(height: 8),
          Text(
            'Step ${currentStepIndex + 1} of ${visibleSteps.length}',
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ],
      ),
    );
  }

  Widget _buildStepContent(WorkflowStep step) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Step number badge
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            decoration: BoxDecoration(
              color: Theme.of(context).primaryColor,
              borderRadius: BorderRadius.circular(20),
            ),
            child: Text(
              'Step ${step.stepOrder}',
              style: const TextStyle(color: Colors.white),
            ),
          ),
          const SizedBox(height: 16),

          // Step name
          Text(
            step.name,
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                  fontWeight: FontWeight.bold,
                ),
          ),
          const SizedBox(height: 8),

          // Step description
          if (step.description != null)
            Text(
              step.description!,
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Colors.grey[600],
                  ),
            ),
          const SizedBox(height: 16),

          // Instructions (expandable)
          if (step.instructions != null)
            ExpansionTile(
              title: const Text('Instructions'),
              children: [
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: Text(step.instructions!),
                ),
              ],
            ),

          // Sample photo
          if (step.samplePhotoUrl != null)
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text('Reference Photo:'),
                const SizedBox(height: 8),
                GestureDetector(
                  onTap: () => _showFullImage(step.samplePhotoUrl!),
                  child: Image.network(
                    step.samplePhotoUrl!,
                    height: 200,
                    width: double.infinity,
                    fit: BoxFit.cover,
                  ),
                ),
                const SizedBox(height: 16),
              ],
            ),

          // Question based on type
          if (step.questionType == 'selection')
            _buildSelectionQuestion(step)
          else
            _buildTextQuestion(step),

          const SizedBox(height: 16),

          // Remarks field
          if (step.allowRemarks)
            TextField(
              decoration: const InputDecoration(
                labelText: 'Additional Remarks (optional)',
                border: OutlineInputBorder(),
              ),
              maxLines: 3,
              onChanged: (value) {
                responses[step.id] = {
                  ...responses[step.id] ?? {},
                  'remarks': value,
                };
              },
            ),
          const SizedBox(height: 16),

          // Photo capture
          if (step.requirePhoto)
            _buildPhotoCapture(step),

          // Skip option
          if (step.allowSkip && !step.isMandatory)
            TextButton(
              onPressed: () => _showSkipDialog(step),
              child: const Text('Skip this step'),
            ),
        ],
      ),
    );
  }

  Widget _buildSelectionQuestion(WorkflowStep step) {
    // Handle Yes/No, Single Choice, Multiple Choice
    return Column(
      children: step.options!.map((option) {
        return RadioListTile(
          title: Text(option.optionText),
          value: option.optionValue,
          groupValue: responses[step.id]?['response_value'],
          onChanged: (value) {
            setState(() {
              responses[step.id] = {
                ...responses[step.id] ?? {},
                'response_value': value,
              };
            });
          },
        );
      }).toList(),
    );
  }

  Widget _buildTextQuestion(WorkflowStep step) {
    return TextField(
      decoration: const InputDecoration(
        labelText: 'Your answer',
        border: OutlineInputBorder(),
      ),
      maxLines: 5,
      onChanged: (value) {
        responses[step.id] = {
          ...responses[step.id] ?? {},
          'response_text': value,
        };
      },
    );
  }

  Widget _buildPhotoCapture(WorkflowStep step) {
    final photos = responses[step.id]?['photos'] ?? [];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Capture Photos (${photos.length} of ${step.photoCount})',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: [
            ...photos.map((photo) => _buildPhotoThumbnail(photo)),
            if (photos.length < step.photoCount)
              _buildAddPhotoButton(step),
          ],
        ),
      ],
    );
  }

  Widget _buildNavigationButtons(WorkflowStep step) {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          if (currentStepIndex > 0)
            Expanded(
              child: OutlinedButton(
                onPressed: _previousStep,
                child: const Text('Previous'),
              ),
            ),
          if (currentStepIndex > 0) const SizedBox(width: 16),
          Expanded(
            child: ElevatedButton(
              onPressed: _canProceed(step) ? _nextStep : null,
              child: Text(
                _isLastStep() ? 'Submit' : 'Next',
              ),
            ),
          ),
        ],
      ),
    );
  }

  bool _canProceed(WorkflowStep step) {
    final response = responses[step.id];

    if (step.isMandatory && !step.allowSkip) {
      // Check if response exists
      if (response == null) return false;

      // Check if response value/text exists
      if (step.questionType == 'selection') {
        if (response['response_value'] == null) return false;
      } else {
        if (response['response_text'] == null ||
            response['response_text'].isEmpty) return false;
      }

      // Check if required photos captured
      if (step.requirePhoto) {
        final photos = response['photos'] ?? [];
        if (photos.length < step.photoCount) return false;
      }
    }

    return true;
  }

  void _nextStep() async {
    final currentStep = _getCurrentVisibleStep(workflow);

    // Submit step response
    await _submitStepResponse(currentStep);

    if (_isLastStep()) {
      // Complete workflow
      context.read<WorkflowBloc>().add(
        CompleteWorkflowEvent(executionId: executionId),
      );
    } else {
      setState(() {
        currentStepIndex++;
      });
    }
  }

  void _previousStep() {
    setState(() {
      currentStepIndex--;
    });
  }

  Future<void> _submitStepResponse(WorkflowStep step) async {
    final response = responses[step.id];

    context.read<WorkflowBloc>().add(
      SubmitStepResponseEvent(
        executionId: executionId,
        stepId: step.id,
        response: response,
      ),
    );
  }
}
```

---

### 5. Location Services

**location_service.dart:**

```dart
import 'package:geolocator/geolocator.dart';

class LocationService {
  Future<Position> getCurrentLocation() async {
    // Check permission
    bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      throw Exception('Location services are disabled');
    }

    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        throw Exception('Location permissions are denied');
      }
    }

    if (permission == LocationPermission.deniedForever) {
      throw Exception('Location permissions are permanently denied');
    }

    return await Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.high,
    );
  }

  double calculateDistance(
    double lat1,
    double lon1,
    double lat2,
    double lon2,
  ) {
    return Geolocator.distanceBetween(lat1, lon1, lat2, lon2);
  }

  bool isWithinGeofence(
    double userLat,
    double userLon,
    double outletLat,
    double outletLon,
    double radiusMeters,
  ) {
    final distance = calculateDistance(userLat, userLon, outletLat, outletLon);
    return distance <= radiusMeters;
  }
}
```

---

### 6. Photo Capture & Upload

**photo_service.dart:**

```dart
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';

class PhotoService {
  final ImagePicker _picker = ImagePicker();

  Future<File?> capturePhoto() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.camera,
      maxWidth: 1920,
      maxHeight: 1080,
      imageQuality: 85,
    );

    if (photo == null) return null;

    // Compress image
    final compressedFile = await _compressImage(File(photo.path));
    return compressedFile;
  }

  Future<File?> pickFromGallery() async {
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 1920,
      maxHeight: 1080,
      imageQuality: 85,
    );

    if (photo == null) return null;
    return File(photo.path);
  }

  Future<File> _compressImage(File file) async {
    final dir = await getTemporaryDirectory();
    final targetPath = '${dir.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg';

    final result = await FlutterImageCompress.compressAndGetFile(
      file.absolute.path,
      targetPath,
      quality: 70,
      minWidth: 1280,
      minHeight: 720,
    );

    return File(result!.path);
  }

  Future<String> uploadPhoto(File file) async {
    final apiClient = ApiClient();
    final formData = FormData.fromMap({
      'file': await MultipartFile.fromFile(
        file.path,
        filename: file.path.split('/').last,
      ),
    });

    final response = await apiClient.dio.post(
      '/mobile/files/upload',
      data: formData,
    );

    return response.data['data']['file']['file_url'];
  }
}
```

---

## Testing Strategy

### Unit Tests

-   Test business logic in use cases
-   Test data models
-   Test utility functions (date formatting, validators, etc.)

### Widget Tests

-   Test individual widgets
-   Test widget interactions
-   Test state changes

### Integration Tests

-   Test complete user flows
-   Test offline sync
-   Test API integration

### Example Unit Test:

```dart
void main() {
  group('LocationService', () {
    final locationService = LocationService();

    test('isWithinGeofence returns true when within radius', () {
      final result = locationService.isWithinGeofence(
        6.927079, 79.861244, // User location
        6.927100, 79.861250, // Outlet location
        100, // 100 meters radius
      );

      expect(result, true);
    });

    test('isWithinGeofence returns false when outside radius', () {
      final result = locationService.isWithinGeofence(
        6.927079, 79.861244,
        6.930000, 79.870000,
        100,
      );

      expect(result, false);
    });
  });
}
```

---

**Document Status:** Final

**Next Steps:** Share with mobile development team for Flutter implementation
