open_wearables_health_sdk 0.0.17 copy "open_wearables_health_sdk: ^0.0.17" to clipboard
open_wearables_health_sdk: ^0.0.17 copied to clipboard

Flutter SDK for secure background health data synchronization from Apple HealthKit (iOS) and Samsung Health / Health Connect (Android) to the Open Wearables platform.

example/lib/main.dart

import 'dart:convert';
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:open_wearables_health_sdk/health_data_type.dart';
import 'package:open_wearables_health_sdk/open_wearables_health_sdk.dart';

// Open Wearables design tokens
class OWColors {
  static const background = Color(0xFF09090B); // zinc-950
  static const surface = Color(0xFF18181B); // zinc-900
  static const surfaceLight = Color(0xFF27272A); // zinc-800
  static const border = Color(0xFF27272A); // zinc-800
  static const borderSubtle = Color(0xFF18181B); // zinc-900
  static const textPrimary = Color(0xFFFFFFFF);
  static const textSecondary = Color(0xFFA1A1AA); // zinc-400
  static const textLabel = Color(0xFFD4D4D8); // zinc-300
  static const textMuted = Color(0xFF71717A); // zinc-500
  static const textFooter = Color(0xFF52525B); // zinc-600
  static const accent = Color(0xFFE4E4E7); // zinc-200
  static const accentIndigo = Color(0xFF6366F1); // indigo
  static const success = Color(0xFF4ADE80); // green-400
  static const error = Color(0xFFEF4444); // red-500
  static const buttonBg = Color(0xFFFFFFFF);
  static const buttonText = Color(0xFF000000);
  static const buttonHover = Color(0xFFE4E4E7); // zinc-200
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  runApp(const MyApp());
}

// Simple in-memory logs (limited to 500 entries for performance)
final List<String> appLogs = [];
const int _maxLogEntries = 500;

// Notifier to trigger rebuilds when logs change (with throttling)
final logUpdateNotifier = ValueNotifier<int>(0);
DateTime _lastLogUpdate = DateTime.now();
bool _pendingUpdate = false;

void _addLog(String message) {
  appLogs.add(message);
  if (appLogs.length > _maxLogEntries) {
    appLogs.removeRange(0, appLogs.length - _maxLogEntries);
  }

  // Throttle UI updates to max 5 per second
  final now = DateTime.now();
  if (now.difference(_lastLogUpdate).inMilliseconds > 200) {
    _lastLogUpdate = now;
    logUpdateNotifier.value++;
    _pendingUpdate = false;
  } else if (!_pendingUpdate) {
    _pendingUpdate = true;
    Future.delayed(const Duration(milliseconds: 200), () {
      if (_pendingUpdate) {
        _lastLogUpdate = DateTime.now();
        logUpdateNotifier.value++;
        _pendingUpdate = false;
      }
    });
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Open Wearables',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        colorScheme: ColorScheme.fromSeed(
          seedColor: OWColors.accent,
          brightness: Brightness.dark,
          surface: OWColors.background,
        ),
        scaffoldBackgroundColor: OWColors.background,
        appBarTheme: const AppBarTheme(
          backgroundColor: OWColors.background,
          elevation: 0,
          scrolledUnderElevation: 0,
          titleTextStyle: TextStyle(
            color: OWColors.textPrimary,
            fontSize: 34,
            fontWeight: FontWeight.bold,
            letterSpacing: -0.5,
          ),
        ),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _hostController = TextEditingController();
  final _invitationCodeController = TextEditingController();

  bool _isLoading = false;
  String _statusMessage = '';

  bool _isSignedIn = false;
  bool _isAuthorized = false;
  bool _isSyncing = false;

  // Sync days back selection
  int? _syncDaysBack; // null = full sync
  final _customDaysController = TextEditingController();
  bool _isCustomDays = false;

  // Provider selection (Android only)
  List<AvailableProvider> _availableProviders = [];
  String? _selectedProviderId;

  @override
  void initState() {
    super.initState();
    _subscribeToNativeLogs();
    _autoConfigureOnStartup();
  }

  void _subscribeToNativeLogs() {
    MethodChannelOpenWearablesHealthSdk.logStream.listen((message) {
      final timestamp = DateTime.now().toIso8601String().split('T').last.split('.').first;
      _addLog('$timestamp $message');
    });

    // Handle auth errors (401) - sign out and redirect to login
    MethodChannelOpenWearablesHealthSdk.authErrorStream.listen((error) {
      final statusCode = error['statusCode'];
      final message = error['message'] ?? 'Authentication error';
      _addLog('🔒 Auth error: $statusCode - $message');
      _handleAuthError();
    });
  }

  Future<void> _handleAuthError() async {
    // Sign out and reset state
    try {
      await OpenWearablesHealthSdk.signOut();
    } catch (_) {}

    if (mounted) {
      setState(() {
        _isSignedIn = false;
        _isAuthorized = false;
        _isSyncing = false;
        _statusMessage = 'Session expired - please sign in again';
      });
    }
  }

  @override
  void dispose() {
    _hostController.dispose();
    _invitationCodeController.dispose();
    _customDaysController.dispose();
    super.dispose();
  }

  Future<void> _autoConfigureOnStartup() async {
    setState(() => _isLoading = true);
    try {
      await OpenWearablesHealthSdk.setLogLevel(OWLogLevel.always);
      final credentials = await OpenWearablesHealthSdk.getStoredCredentials();
      final hasUserId = credentials['userId'] != null && (credentials['userId'] as String).isNotEmpty;
      final hasAccessToken =
          (credentials['accessToken'] != null && (credentials['accessToken'] as String).isNotEmpty) ||
          (credentials['apiKey'] != null && (credentials['apiKey'] as String).isNotEmpty);
      final hasHost = credentials['host'] != null && (credentials['host'] as String).isNotEmpty;
      final wasSyncActive = credentials['isSyncActive'] == true;

      if (hasHost) {
        setState(() {
          _hostController.text = credentials['host'] as String;
        });
      }

      // Restore selected provider if stored
      final storedProvider = credentials['provider'] as String?;
      if (storedProvider != null) {
        setState(() => _selectedProviderId = storedProvider);
      }

      if (hasUserId && hasAccessToken && hasHost && wasSyncActive) {
        await OpenWearablesHealthSdk.configure(host: credentials['host'] as String);
        _checkStatus();
        _setStatus('Session restored');
      }

      if (Platform.isAndroid) {
        await _loadAvailableProviders();
        await OpenWearablesHealthSdk.setSyncNotification(
          title: '🏃 Open Wearables',
          text: '⚡ Sync in progress — keeping your health data fresh 💪',
        );
      }
    } catch (e) {
      debugPrint('Auto-configure failed: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _loadAvailableProviders() async {
    try {
      final providers = await OpenWearablesHealthSdk.getAvailableProviders();
      setState(() {
        _availableProviders = providers;
        if (_selectedProviderId == null && providers.isNotEmpty) {
          _selectedProviderId = providers.first.id;
        }
      });
    } catch (e) {
      debugPrint('Failed to load providers: $e');
    }
  }

  Future<void> _selectProvider(String providerId) async {
    final provider = AndroidHealthProvider.fromId(providerId);
    if (provider == null) return;

    setState(() => _isLoading = true);
    try {
      await OpenWearablesHealthSdk.setProvider(provider);
      setState(() {
        _selectedProviderId = providerId;
        _isAuthorized = false;
      });
      _setStatus('Provider set to ${provider.displayName}');
    } catch (e) {
      _setStatus('Failed to set provider: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _connectWithInvitationCode() async {
    final host = _hostController.text.trim();
    final invitationCode = _invitationCodeController.text.trim();

    if (host.isEmpty || invitationCode.isEmpty) {
      _setStatus('Please fill Host and Invitation Code');
      return;
    }

    setState(() => _isLoading = true);

    try {
      _setStatus('Redeeming invitation code...');

      // Build redeem URL: {host}/api/v1/invitation-code/redeem
      final h = host.endsWith('/') ? host.substring(0, host.length - 1) : host;
      final redeemUrl = Uri.parse('$h/api/v1/invitation-code/redeem');

      final response = await http.post(
        redeemUrl,
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({'code': invitationCode}),
      );

      if (response.statusCode != 200) {
        _setStatus('Redeem failed (${response.statusCode}): ${response.body}');
        return;
      }

      final data = jsonDecode(response.body) as Map<String, dynamic>;
      final accessToken = data['access_token'] as String?;
      final refreshToken = data['refresh_token'] as String?;
      final userId = data['user_id'] as String?;

      if (accessToken == null || refreshToken == null || userId == null) {
        _setStatus('Invalid response from server');
        return;
      }

      // Configure SDK with host
      await OpenWearablesHealthSdk.configure(host: host);
      _checkStatus();

      // Sign in with the received credentials
      _setStatus('Signing in...');
      final bearerToken = accessToken.startsWith('Bearer ') ? accessToken : 'Bearer $accessToken';
      await OpenWearablesHealthSdk.signIn(userId: userId, accessToken: bearerToken, refreshToken: refreshToken);

      _setStatus('Connected successfully');
      _checkStatus();

      if (Platform.isAndroid) {
        await _loadAvailableProviders();
      }
    } catch (e) {
      _setStatus('Connection failed: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  String _providerDisplayName(String id) {
    final match = _availableProviders.where((p) => p.id == id);
    if (match.isNotEmpty) return match.first.displayName;
    return AndroidHealthProvider.fromId(id)?.displayName ?? id;
  }

  void _checkStatus() {
    setState(() {
      _isSignedIn = OpenWearablesHealthSdk.isSignedIn;
      _isSyncing = OpenWearablesHealthSdk.isSyncActive;
      if (_isSyncing) _isAuthorized = true;
    });
  }

  void _setStatus(String message) {
    setState(() => _statusMessage = message);
    final log = '${DateTime.now().toIso8601String().split('T').last.split('.').first} $message';
    _addLog(log);
    debugPrint('[Demo] $message');
  }

  Future<void> _signOut() async {
    setState(() => _isLoading = true);
    try {
      await OpenWearablesHealthSdk.signOut();
      _setStatus('Signed out');
      _checkStatus();
      setState(() {
        _isAuthorized = false;
        _isSyncing = false;
        _invitationCodeController.clear();
      });
    } catch (e) {
      _setStatus('Error: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _requestAuthorization() async {
    setState(() => _isLoading = true);
    try {
      final authorized = await OpenWearablesHealthSdk.requestAuthorization(types: HealthDataType.values);
      setState(() => _isAuthorized = authorized);
      _setStatus(authorized ? 'Authorized' : 'Authorization denied');
    } on NotSignedInException {
      _setStatus('Sign in first');
    } catch (e) {
      _setStatus('Error: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _startBackgroundSync() async {
    setState(() => _isLoading = true);
    try {
      final label = _syncDaysBack != null ? 'last $_syncDaysBack days' : 'full history';
      final started = await OpenWearablesHealthSdk.startBackgroundSync(syncDaysBack: _syncDaysBack);
      setState(() => _isSyncing = started);
      _setStatus(started ? 'Sync started ($label)' : 'Could not start sync');
    } on NotSignedInException {
      _setStatus('Sign in first');
    } catch (e) {
      _setStatus('Error: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _stopBackgroundSync() async {
    setState(() => _isLoading = true);
    try {
      await OpenWearablesHealthSdk.stopBackgroundSync();
      setState(() => _isSyncing = false);
      _setStatus('Sync stopped');
    } catch (e) {
      _setStatus('Error: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _syncNow() async {
    setState(() => _isLoading = true);
    try {
      await OpenWearablesHealthSdk.syncNow();
      _setStatus('Sync triggered');
    } on NotSignedInException {
      _setStatus('Sign in first');
    } catch (e) {
      _setStatus('Error: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            pinned: true,
            expandedHeight: 100,
            backgroundColor: OWColors.background,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text(
                'Open Wearables',
                style: TextStyle(color: OWColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w600),
              ),
              titlePadding: const EdgeInsets.only(left: 20, bottom: 16),
            ),
            actions: [
              CupertinoButton(
                padding: const EdgeInsets.all(12),
                child: const Icon(CupertinoIcons.doc_text, size: 24, color: OWColors.textSecondary),
                onPressed: () => Navigator.of(context).push(CupertinoPageRoute(builder: (c) => const LogsPage())),
              ),
            ],
          ),

          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _buildStatusCard(),
                  const SizedBox(height: 24),

                  if (!_isSignedIn) ...[
                    _buildLoginSection(),
                  ] else ...[
                    if (Platform.isAndroid && _availableProviders.isNotEmpty) ...[
                      _buildProviderSection(),
                      const SizedBox(height: 16),
                    ],
                    if (_isAuthorized && !_isSyncing) ...[_buildSyncRangeSection()],
                    _buildActionsSection(),
                  ],

                  if (_statusMessage.isNotEmpty) ...[const SizedBox(height: 24), _buildStatusMessage()],
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildStatusCard() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: OWColors.surface.withValues(alpha: 0.7),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: OWColors.border),
      ),
      child: Row(
        children: [
          Container(
            width: 56,
            height: 56,
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              gradient: LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: _isSyncing
                    ? [OWColors.success, OWColors.success.withValues(alpha: 0.7)]
                    : [OWColors.error, OWColors.error.withValues(alpha: 0.7)],
              ),
              boxShadow: [
                BoxShadow(
                  color: (_isSyncing ? OWColors.success : OWColors.error).withValues(alpha: 0.3),
                  blurRadius: 12,
                  spreadRadius: 2,
                ),
              ],
            ),
            child: Icon(
              _isSyncing ? CupertinoIcons.checkmark_alt : CupertinoIcons.xmark,
              color: Colors.white,
              size: 28,
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  _isSyncing ? 'Syncing Active' : 'Not Syncing',
                  style: const TextStyle(
                    fontSize: 20,
                    fontWeight: FontWeight.w600,
                    letterSpacing: -0.3,
                    color: OWColors.textPrimary,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  _isSignedIn
                      ? 'Connected${_selectedProviderId != null ? ' via ${_providerDisplayName(_selectedProviderId!)}' : ''}'
                      : 'Not connected',
                  style: const TextStyle(fontSize: 15, color: OWColors.textSecondary, letterSpacing: -0.2),
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
          ),
          if (_isLoading) const CupertinoActivityIndicator(color: OWColors.accent),
        ],
      ),
    );
  }

  Widget _buildLoginSection() {
    return Container(
      decoration: BoxDecoration(
        color: OWColors.surface.withValues(alpha: 0.7),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: OWColors.border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Padding(
            padding: EdgeInsets.fromLTRB(20, 20, 20, 12),
            child: Text(
              'CONNECT',
              style: TextStyle(
                fontSize: 13,
                fontWeight: FontWeight.w600,
                color: OWColors.textMuted,
                letterSpacing: 0.5,
              ),
            ),
          ),
          _buildTextField(
            controller: _hostController,
            placeholder: 'Host (e.g. https://api.example.com)',
            icon: CupertinoIcons.globe,
            keyboardType: TextInputType.url,
          ),
          _buildDivider(),
          _buildTextField(
            controller: _invitationCodeController,
            placeholder: 'Invitation Code',
            icon: CupertinoIcons.ticket,
          ),
          Padding(
            padding: const EdgeInsets.all(20),
            child: SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _isLoading ? null : _connectWithInvitationCode,
                style: ElevatedButton.styleFrom(
                  backgroundColor: OWColors.buttonBg,
                  foregroundColor: OWColors.buttonText,
                  disabledBackgroundColor: OWColors.buttonHover,
                  padding: const EdgeInsets.symmetric(vertical: 14),
                  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
                  elevation: 0,
                ),
                child: _isLoading
                    ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(strokeWidth: 2, color: OWColors.buttonText),
                      )
                    : const Text('Connect', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTextField({
    required TextEditingController controller,
    required String placeholder,
    required IconData icon,
    bool obscureText = false,
    TextInputType? keyboardType,
  }) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
      child: Row(
        children: [
          Icon(icon, color: OWColors.textMuted, size: 24),
          const SizedBox(width: 12),
          Expanded(
            child: CupertinoTextField(
              controller: controller,
              placeholder: placeholder,
              obscureText: obscureText,
              keyboardType: keyboardType,
              autocorrect: false,
              padding: EdgeInsets.zero,
              decoration: const BoxDecoration(),
              style: const TextStyle(fontSize: 17, color: OWColors.textPrimary),
              placeholderStyle: const TextStyle(fontSize: 17, color: OWColors.textMuted),
              cursorColor: OWColors.accent,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildDivider() {
    return Padding(
      padding: const EdgeInsets.only(left: 56),
      child: Divider(height: 1, color: OWColors.border.withValues(alpha: 0.5)),
    );
  }

  Widget _buildProviderSection() {
    return Container(
      decoration: BoxDecoration(
        color: OWColors.surface.withValues(alpha: 0.7),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: OWColors.border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Padding(
            padding: EdgeInsets.fromLTRB(20, 20, 20, 4),
            child: Text(
              'HEALTH PROVIDER',
              style: TextStyle(
                fontSize: 13,
                fontWeight: FontWeight.w600,
                color: OWColors.textMuted,
                letterSpacing: 0.5,
              ),
            ),
          ),
          const Padding(
            padding: EdgeInsets.fromLTRB(20, 0, 20, 12),
            child: Text(
              'Select where to read health data from',
              style: TextStyle(fontSize: 14, color: OWColors.textFooter),
            ),
          ),
          for (int i = 0; i < _availableProviders.length; i++) ...[
            if (i > 0)
              Padding(
                padding: const EdgeInsets.only(left: 64),
                child: Divider(height: 1, color: OWColors.border.withValues(alpha: 0.5)),
              ),
            _buildProviderOption(_availableProviders[i]),
          ],
          const SizedBox(height: 8),
        ],
      ),
    );
  }

  Widget _buildProviderOption(AvailableProvider provider) {
    final isSelected = _selectedProviderId == provider.id;
    final iconData = provider.id == 'samsung' ? CupertinoIcons.device_phone_portrait : CupertinoIcons.heart_circle;

    return CupertinoButton(
      padding: EdgeInsets.zero,
      onPressed: _isLoading
          ? null
          : () {
              if (!isSelected) _selectProvider(provider.id);
            },
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
        child: Row(
          children: [
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(
                color: (isSelected ? OWColors.accentIndigo : OWColors.textMuted).withValues(alpha: 0.15),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Icon(iconData, color: isSelected ? OWColors.accentIndigo : OWColors.textMuted, size: 20),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    provider.displayName,
                    style: TextStyle(
                      fontSize: 17,
                      fontWeight: FontWeight.w500,
                      color: isSelected ? OWColors.textPrimary : OWColors.textSecondary,
                      letterSpacing: -0.2,
                    ),
                  ),
                  Text(
                    provider.id == 'samsung' ? 'Samsung devices with Samsung Health' : 'Universal Android health hub',
                    style: const TextStyle(fontSize: 14, color: OWColors.textMuted, letterSpacing: -0.1),
                  ),
                ],
              ),
            ),
            AnimatedContainer(
              duration: const Duration(milliseconds: 200),
              width: 24,
              height: 24,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: isSelected ? OWColors.accentIndigo : Colors.transparent,
                border: Border.all(color: isSelected ? OWColors.accentIndigo : OWColors.textFooter, width: 2),
              ),
              child: isSelected ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSyncRangeSection() {
    final presets = <({String label, int? days})>[
      (label: 'Full Sync', days: null),
      (label: '1 Day', days: 1),
      (label: '30 Days', days: 30),
      (label: '90 Days', days: 90),
      (label: '365 Days', days: 365),
    ];

    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Container(
        decoration: BoxDecoration(
          color: OWColors.surface.withValues(alpha: 0.7),
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: OWColors.border),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Padding(
              padding: EdgeInsets.fromLTRB(20, 20, 20, 4),
              child: Text(
                'SYNC RANGE',
                style: TextStyle(
                  fontSize: 13,
                  fontWeight: FontWeight.w600,
                  color: OWColors.textMuted,
                  letterSpacing: 0.5,
                ),
              ),
            ),
            const Padding(
              padding: EdgeInsets.fromLTRB(20, 0, 20, 12),
              child: Text(
                'How far back to sync health data',
                style: TextStyle(fontSize: 14, color: OWColors.textFooter),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
              child: Wrap(
                spacing: 8,
                runSpacing: 8,
                children: [
                  for (final preset in presets)
                    _buildRangeChip(
                      label: preset.label,
                      isSelected: !_isCustomDays && _syncDaysBack == preset.days,
                      onTap: () => setState(() {
                        _syncDaysBack = preset.days;
                        _isCustomDays = false;
                      }),
                    ),
                  _buildRangeChip(
                    label: 'Custom',
                    isSelected: _isCustomDays,
                    onTap: () => setState(() {
                      _isCustomDays = true;
                      if (_customDaysController.text.isNotEmpty) {
                        _syncDaysBack = int.tryParse(_customDaysController.text);
                      }
                    }),
                  ),
                ],
              ),
            ),
            if (_isCustomDays)
              Padding(
                padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
                child: Row(
                  children: [
                    const Icon(CupertinoIcons.number, color: OWColors.textMuted, size: 20),
                    const SizedBox(width: 12),
                    Expanded(
                      child: CupertinoTextField(
                        controller: _customDaysController,
                        placeholder: 'Number of days',
                        keyboardType: TextInputType.number,
                        padding: const EdgeInsets.symmetric(vertical: 10),
                        decoration: const BoxDecoration(),
                        style: const TextStyle(fontSize: 17, color: OWColors.textPrimary),
                        placeholderStyle: const TextStyle(fontSize: 17, color: OWColors.textMuted),
                        cursorColor: OWColors.accent,
                        onChanged: (value) {
                          setState(() {
                            _syncDaysBack = int.tryParse(value);
                          });
                        },
                      ),
                    ),
                    if (_syncDaysBack != null && _isCustomDays)
                      Text('days', style: const TextStyle(fontSize: 15, color: OWColors.textSecondary)),
                  ],
                ),
              ),
            const SizedBox(height: 16),
          ],
        ),
      ),
    );
  }

  Widget _buildRangeChip({required String label, required bool isSelected, required VoidCallback onTap}) {
    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
        decoration: BoxDecoration(
          color: isSelected ? OWColors.accentIndigo : OWColors.surfaceLight,
          borderRadius: BorderRadius.circular(20),
          border: Border.all(color: isSelected ? OWColors.accentIndigo : OWColors.border),
        ),
        child: Text(
          label,
          style: TextStyle(
            fontSize: 14,
            fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
            color: isSelected ? Colors.white : OWColors.textSecondary,
          ),
        ),
      ),
    );
  }

  Widget _buildActionsSection() {
    return Container(
      decoration: BoxDecoration(
        color: OWColors.surface.withValues(alpha: 0.7),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: OWColors.border),
      ),
      child: Column(
        children: [
          if (!_isAuthorized)
            _buildActionTile(
              icon: CupertinoIcons.heart,
              iconColor: OWColors.accent,
              title: 'Authorize Health',
              subtitle: 'Grant access to health data',
              onTap: _requestAuthorization,
            ),
          if (_isAuthorized) ...[
            _buildActionTile(
              icon: _isSyncing ? CupertinoIcons.pause : CupertinoIcons.play,
              iconColor: OWColors.success,
              title: _isSyncing ? 'Stop Sync' : 'Start Sync',
              subtitle: _isSyncing
                  ? 'Background sync is active'
                  : 'Sync ${_syncDaysBack != null ? 'last $_syncDaysBack days' : 'full history'}',
              onTap: _isSyncing ? _stopBackgroundSync : _startBackgroundSync,
            ),
            _buildDivider(),
            _buildActionTile(
              icon: CupertinoIcons.arrow_2_circlepath,
              iconColor: OWColors.accentIndigo,
              title: 'Sync Now',
              subtitle: 'Force an immediate sync',
              onTap: _syncNow,
            ),
          ],
          _buildDivider(),
          _buildActionTile(
            icon: CupertinoIcons.square_arrow_left,
            iconColor: OWColors.error,
            title: 'Disconnect',
            subtitle: 'Sign out and stop syncing',
            onTap: _signOut,
            destructive: true,
          ),
        ],
      ),
    );
  }

  Widget _buildActionTile({
    required IconData icon,
    required Color iconColor,
    required String title,
    required String subtitle,
    required VoidCallback onTap,
    bool destructive = false,
  }) {
    return CupertinoButton(
      padding: EdgeInsets.zero,
      onPressed: _isLoading ? null : onTap,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
        child: Row(
          children: [
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(
                color: iconColor.withValues(alpha: 0.15),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Icon(icon, color: iconColor, size: 20),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: TextStyle(
                      fontSize: 17,
                      fontWeight: FontWeight.w500,
                      color: destructive ? OWColors.error : OWColors.textPrimary,
                      letterSpacing: -0.2,
                    ),
                  ),
                  Text(subtitle, style: const TextStyle(fontSize: 14, color: OWColors.textMuted, letterSpacing: -0.1)),
                ],
              ),
            ),
            const Icon(CupertinoIcons.chevron_right, color: OWColors.textFooter, size: 20),
          ],
        ),
      ),
    );
  }

  Widget _buildStatusMessage() {
    final isError = _statusMessage.toLowerCase().contains('error') || _statusMessage.toLowerCase().contains('failed');
    final statusColor = isError ? OWColors.error : OWColors.success;
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: statusColor.withValues(alpha: 0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: statusColor.withValues(alpha: 0.25)),
      ),
      child: Row(
        children: [
          Icon(
            isError ? CupertinoIcons.exclamationmark_circle : CupertinoIcons.checkmark_circle,
            color: statusColor,
            size: 22,
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              _statusMessage,
              style: TextStyle(fontSize: 15, color: statusColor, fontWeight: FontWeight.w500),
            ),
          ),
        ],
      ),
    );
  }
}

class LogsPage extends StatefulWidget {
  const LogsPage({super.key});

  @override
  State<LogsPage> createState() => _LogsPageState();
}

class _LogsPageState extends State<LogsPage> {
  final _searchController = TextEditingController();
  final _scrollController = ScrollController();
  String _searchQuery = '';
  List<String> _cachedFilteredLogs = [];
  int _lastLogCount = 0;
  String _lastSearchQuery = '';

  @override
  void dispose() {
    _searchController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  List<String> _getFilteredLogs() {
    // Cache filtered logs to avoid recomputing on every build
    if (_lastLogCount == appLogs.length && _lastSearchQuery == _searchQuery) {
      return _cachedFilteredLogs;
    }

    _lastLogCount = appLogs.length;
    _lastSearchQuery = _searchQuery;

    if (_searchQuery.isEmpty) {
      _cachedFilteredLogs = appLogs.reversed.toList();
    } else {
      final query = _searchQuery.toLowerCase();
      _cachedFilteredLogs = appLogs.reversed.where((log) => log.toLowerCase().contains(query)).toList();
    }
    return _cachedFilteredLogs;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: OWColors.background,
      appBar: AppBar(
        backgroundColor: OWColors.background,
        title: const Text(
          'Sync Logs',
          style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: OWColors.textPrimary),
        ),
        leading: CupertinoButton(
          padding: EdgeInsets.zero,
          child: const Icon(CupertinoIcons.back, color: OWColors.accent),
          onPressed: () => Navigator.of(context).pop(),
        ),
        actions: [
          CupertinoButton(
            padding: const EdgeInsets.all(12),
            child: const Icon(CupertinoIcons.trash, color: OWColors.error),
            onPressed: () {
              appLogs.clear();
              _cachedFilteredLogs = [];
              _lastLogCount = 0;
              logUpdateNotifier.value++;
            },
          ),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
            child: CupertinoSearchTextField(
              controller: _searchController,
              placeholder: 'Search in logs...',
              onChanged: (value) => setState(() => _searchQuery = value),
              style: const TextStyle(color: OWColors.textPrimary),
              backgroundColor: OWColors.surface,
              placeholderStyle: const TextStyle(color: OWColors.textMuted),
              prefixIcon: const Icon(CupertinoIcons.search, color: OWColors.textMuted),
            ),
          ),
          Expanded(
            child: ValueListenableBuilder<int>(
              valueListenable: logUpdateNotifier,
              builder: (context, _, __) {
                final logs = _getFilteredLogs();

                if (appLogs.isEmpty) {
                  return Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        const Icon(CupertinoIcons.doc_text, size: 48, color: OWColors.textFooter),
                        const SizedBox(height: 12),
                        const Text('No logs yet', style: TextStyle(fontSize: 17, color: OWColors.textMuted)),
                      ],
                    ),
                  );
                }

                if (logs.isEmpty) {
                  return Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        const Icon(CupertinoIcons.search, size: 48, color: OWColors.textFooter),
                        const SizedBox(height: 12),
                        const Text('No results', style: TextStyle(fontSize: 17, color: OWColors.textMuted)),
                      ],
                    ),
                  );
                }

                return ListView.builder(
                  controller: _scrollController,
                  padding: const EdgeInsets.all(16),
                  itemCount: logs.length,
                  addAutomaticKeepAlives: false,
                  addRepaintBoundaries: true,
                  itemBuilder: (context, index) {
                    return Padding(
                      padding: const EdgeInsets.only(bottom: 8),
                      child: _LogItem(log: logs[index]),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

class _LogItem extends StatelessWidget {
  final String log;

  const _LogItem({required this.log});

  @override
  Widget build(BuildContext context) {
    final Color dotColor;
    if (log.contains('❌')) {
      dotColor = OWColors.error;
    } else if (log.contains('✅')) {
      dotColor = OWColors.success;
    } else {
      dotColor = OWColors.textFooter;
    }

    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: OWColors.surface,
        borderRadius: const BorderRadius.all(Radius.circular(10)),
        border: Border.all(color: OWColors.border.withValues(alpha: 0.5)),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 8,
            height: 8,
            margin: const EdgeInsets.only(top: 6),
            decoration: BoxDecoration(shape: BoxShape.circle, color: dotColor),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              log,
              style: const TextStyle(fontSize: 13, fontFamily: 'Menlo', color: OWColors.textSecondary, height: 1.4),
            ),
          ),
        ],
      ),
    );
  }
}
1
likes
150
points
1.2k
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for secure background health data synchronization from Apple HealthKit (iOS) and Samsung Health / Health Connect (Android) to the Open Wearables platform.

Homepage
Repository (GitHub)
View/report issues

Topics

#health #healthkit #wearables #android #background-sync

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on open_wearables_health_sdk

Packages that implement open_wearables_health_sdk