adds syncing

This commit is contained in:
2025-08-27 16:17:21 +02:00
parent 1191d06e53
commit 709cf2cbd9
24 changed files with 3809 additions and 226 deletions

View File

@@ -0,0 +1,287 @@
# WebDAV Cloud Sync Implementation
## Overview
This document describes the WebDAV cloud sync implementation for the Supplements Tracker Flutter app. The implementation allows users to synchronize their supplement data across multiple devices using WebDAV-compatible servers like Nextcloud, ownCloud, or any standard WebDAV server.
## Architecture
### Core Components
#### 1. Data Models Enhanced with Sync Metadata
All core data models (`Supplement`, `SupplementIntake`, `Ingredient`) have been enhanced with sync metadata:
- `syncId: String` - Unique identifier for sync operations (UUID)
- `lastModified: DateTime` - Timestamp of last modification
- `syncStatus: SyncStatus` - Current sync state (pending, synced, modified, conflict, etc.)
- `isDeleted: bool` - Soft delete flag for sync purposes
#### 2. Sync Enumerations (`lib/models/sync_enums.dart`)
- `SyncStatus` - Track individual record sync states
- `SyncOperationStatus` - Overall sync operation states
- `ConflictResolutionStrategy` - How to handle conflicts
- `SyncFrequency` - Auto-sync timing options
- `ConflictType` - Types of sync conflicts
#### 3. Sync Data Models (`lib/models/sync_data.dart`)
- `SyncData` - Complete data structure for WebDAV JSON format
- `SyncConflict` - Represents conflicts between local and remote data
- `SyncResult` - Results and statistics from sync operations
- `SyncStatistics` - Detailed sync operation metrics
#### 4. WebDAV Sync Service (`lib/services/webdav_sync_service.dart`)
Core service handling WebDAV communication:
- Server configuration and authentication
- Data upload/download operations
- Conflict detection and basic resolution
- Network connectivity checking
- Device identification
#### 5. Sync Provider (`lib/providers/sync_provider.dart`)
State management layer for sync operations:
- Manages sync status and progress
- Handles user configuration
- Coordinates between WebDAV service and UI
- Manages conflict resolution workflow
#### 6. UI Components
- `SyncSettingsScreen` - Complete WebDAV configuration interface
- Integration with existing settings screen
- Real-time sync status indicators
- Conflict resolution dialogs
### Database Schema Changes
The database has been upgraded to version 6 with sync support:
```sql
-- New sync columns added to existing tables
ALTER TABLE supplements ADD COLUMN syncId TEXT NOT NULL UNIQUE;
ALTER TABLE supplements ADD COLUMN lastModified TEXT NOT NULL;
ALTER TABLE supplements ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE supplements ADD COLUMN isDeleted INTEGER NOT NULL DEFAULT 0;
-- Similar columns added to supplement_intakes table
-- New sync metadata table
CREATE TABLE sync_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
);
-- Device info table for multi-device conflict resolution
CREATE TABLE device_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
);
```
## Sync Process Flow
### 1. Configuration Phase
1. User enters WebDAV server URL, username, and password/app password
2. System tests connection and validates credentials
3. Creates sync directory on server if needed
4. Stores encrypted credentials locally using `flutter_secure_storage`
### 2. Sync Operation
1. Check network connectivity
2. Download remote sync data (JSON file from WebDAV)
3. Compare with local data using timestamps and sync IDs
4. Detect conflicts (modification, deletion, creation conflicts)
5. Merge data according to conflict resolution strategy
6. Upload merged data back to server
7. Update local sync status
### 3. Conflict Resolution
- **Last-write-wins**: Prefer most recently modified record
- **Prefer Local**: Always keep local changes
- **Prefer Remote**: Always keep remote changes
- **Manual**: Present conflicts to user for individual resolution
## Security Considerations
### Data Protection
- Credentials stored using `flutter_secure_storage` with platform encryption
- Support for app passwords instead of main account passwords
- No sensitive data logged in release builds
### Network Security
- HTTPS enforced for WebDAV connections
- Connection testing before storing credentials
- Proper error handling for authentication failures
## Supported WebDAV Servers
### Tested Compatibility
- **Nextcloud** - Full compatibility with automatic path detection
- **ownCloud** - Full compatibility with automatic path detection
- **Generic WebDAV** - Manual path configuration required
### Server Requirements
- WebDAV protocol support
- File read/write permissions
- Directory creation permissions
- Basic or digest authentication
## Data Synchronization Format
### JSON Structure
```json
{
"version": 1,
"deviceId": "device_12345_67890",
"deviceName": "John's Phone",
"syncTimestamp": "2024-01-15T10:30:00.000Z",
"supplements": [
{
"syncId": "uuid-supplement-1",
"name": "Vitamin D3",
"ingredients": [...],
"lastModified": "2024-01-15T09:15:00.000Z",
"syncStatus": "synced",
"isDeleted": false,
// ... other supplement fields
}
],
"intakes": [
{
"syncId": "uuid-intake-1",
"supplementId": 1,
"takenAt": "2024-01-15T08:00:00.000Z",
"lastModified": "2024-01-15T08:00:30.000Z",
"syncStatus": "synced",
"isDeleted": false,
// ... other intake fields
}
],
"metadata": {
"totalSupplements": 5,
"totalIntakes": 150
}
}
```
## Usage Instructions
### Initial Setup
1. Navigate to Settings → Cloud Sync
2. Enter your WebDAV server details:
- Server URL (e.g., `https://cloud.example.com`)
- Username
- Password or app password
- Optional device name
3. Test connection
4. Configure sync preferences
### Sync Options
- **Manual Sync**: Sync on demand via button press
- **Auto Sync**: Automatic background sync at configured intervals
- Every 15 minutes
- Hourly
- Every 6 hours
- Daily
### Conflict Resolution
- Configure preferred resolution strategy in settings
- Review individual conflicts when they occur
- Auto-resolve based on chosen strategy
## Implementation Status
### ✅ Completed (Phase 1)
- Core sync architecture and data models
- WebDAV service implementation
- Database schema with sync support
- Basic UI for configuration and management
- Manual sync operations
- Conflict detection
- Secure credential storage
### 🔄 Future Enhancements (Phase 2+)
- Background sync scheduling
- Advanced conflict resolution UI
- Sync history and logs
- Client-side encryption option
- Multiple server support
- Bandwidth optimization
- Offline queue management
## Dependencies Added
```yaml
dependencies:
webdav_client: ^1.2.2 # WebDAV protocol client
connectivity_plus: ^6.0.5 # Network connectivity checking
flutter_secure_storage: ^9.2.2 # Secure credential storage
uuid: ^4.5.0 # UUID generation for sync IDs
crypto: ^3.0.5 # Data integrity verification
```
## File Structure
```
lib/
├── models/
│ ├── sync_enums.dart # Sync-related enumerations
│ ├── sync_data.dart # Sync data models
│ ├── supplement.dart # Enhanced with sync metadata
│ ├── supplement_intake.dart # Enhanced with sync metadata
│ └── ingredient.dart # Enhanced with sync metadata
├── services/
│ ├── webdav_sync_service.dart # WebDAV communication
│ └── database_helper.dart # Enhanced with sync methods
├── providers/
│ └── sync_provider.dart # Sync state management
└── screens/
└── sync_settings_screen.dart # Sync configuration UI
```
## Error Handling
The implementation includes comprehensive error handling for:
- Network connectivity issues
- Authentication failures
- Server unavailability
- Malformed sync data
- Storage permission issues
- Conflicting concurrent modifications
## Testing Recommendations
### Unit Tests
- Sync data serialization/deserialization
- Conflict detection algorithms
- Merge logic validation
### Integration Tests
- WebDAV server communication
- Database sync operations
- Cross-device sync scenarios
### User Testing
- Multi-device sync workflows
- Network interruption recovery
- Large dataset synchronization
- Conflict resolution user experience
## Performance Considerations
- Incremental sync (only changed data)
- Compression for large datasets
- Connection timeout handling
- Memory-efficient JSON processing
- Background processing for large operations
This implementation provides a solid foundation for cloud synchronization while maintaining data integrity and user experience across multiple devices.

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/sync_provider.dart';
import 'screens/home_screen.dart';
void main() {
@@ -21,6 +23,11 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(
create: (context) => SettingsProvider()..initialize(),
),
ChangeNotifierProxyProvider<SupplementProvider, SyncProvider>(
create: (context) => SyncProvider(context.read<SupplementProvider>())..initialize(),
update: (context, supplementProvider, syncProvider) =>
syncProvider ?? SyncProvider(supplementProvider)..initialize(),
),
],
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {

View File

@@ -1,14 +1,26 @@
import 'sync_enums.dart';
class Ingredient {
final int? id;
final String name; // e.g., "Vitamin K2", "Vitamin D3"
final double amount; // e.g., 75, 20
final String unit; // e.g., "mcg", "mg", "IU"
// Sync metadata
final String syncId;
final DateTime lastModified;
final SyncStatus syncStatus;
final bool isDeleted;
const Ingredient({
this.id,
required this.name,
required this.amount,
required this.unit,
required this.syncId,
required this.lastModified,
this.syncStatus = SyncStatus.pending,
this.isDeleted = false,
});
Map<String, dynamic> toMap() {
@@ -17,6 +29,10 @@ class Ingredient {
'name': name,
'amount': amount,
'unit': unit,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -26,6 +42,17 @@ class Ingredient {
name: map['name'],
amount: map['amount']?.toDouble() ?? 0.0,
unit: map['unit'],
syncId: map['syncId'] ?? '',
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending,
)
: SyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -34,12 +61,20 @@ class Ingredient {
String? name,
double? amount,
String? unit,
String? syncId,
DateTime? lastModified,
SyncStatus? syncStatus,
bool? isDeleted,
}) {
return Ingredient(
id: id ?? this.id,
name: name ?? this.name,
amount: amount ?? this.amount,
unit: unit ?? this.unit,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
@@ -54,11 +89,12 @@ class Ingredient {
return other is Ingredient &&
other.name == name &&
other.amount == amount &&
other.unit == unit;
other.unit == unit &&
other.syncId == syncId;
}
@override
int get hashCode {
return name.hashCode ^ amount.hashCode ^ unit.hashCode;
return name.hashCode ^ amount.hashCode ^ unit.hashCode ^ syncId.hashCode;
}
}

View File

@@ -1,6 +1,10 @@
import 'ingredient.dart';
import 'dart:convert';
import 'package:uuid/uuid.dart';
import 'ingredient.dart';
import 'sync_enums.dart';
class Supplement {
final int? id;
final String name;
@@ -14,6 +18,12 @@ class Supplement {
final DateTime createdAt;
final bool isActive;
// Sync metadata
final String syncId;
final DateTime lastModified;
final SyncStatus syncStatus;
final bool isDeleted;
Supplement({
this.id,
required this.name,
@@ -26,7 +36,12 @@ class Supplement {
this.notes,
required this.createdAt,
this.isActive = true,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = SyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
// Helper getters
double get totalDosagePerIntake {
@@ -66,6 +81,10 @@ class Supplement {
'notes': notes,
'createdAt': createdAt.toIso8601String(),
'isActive': isActive ? 1 : 0,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -98,6 +117,17 @@ class Supplement {
notes: map['notes'],
createdAt: DateTime.parse(map['createdAt']),
isActive: map['isActive'] == 1,
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending,
)
: SyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -113,6 +143,10 @@ class Supplement {
String? notes,
DateTime? createdAt,
bool? isActive,
String? syncId,
DateTime? lastModified,
SyncStatus? syncStatus,
bool? isDeleted,
}) {
return Supplement(
id: id ?? this.id,
@@ -126,6 +160,34 @@ class Supplement {
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new supplement with updated sync status and timestamp
Supplement markAsModified() {
return copyWith(
lastModified: DateTime.now(),
syncStatus: SyncStatus.modified,
);
}
/// Create a new supplement marked as synced
Supplement markAsSynced() {
return copyWith(
syncStatus: SyncStatus.synced,
);
}
/// Create a new supplement marked for deletion
Supplement markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: SyncStatus.modified,
);
}
}

View File

@@ -1,3 +1,7 @@
import 'package:uuid/uuid.dart';
import 'sync_enums.dart';
class SupplementIntake {
final int? id;
final int supplementId;
@@ -6,6 +10,12 @@ class SupplementIntake {
final double unitsTaken; // Number of units taken (can be fractional)
final String? notes;
// Sync metadata
final String syncId;
final DateTime lastModified;
final SyncStatus syncStatus;
final bool isDeleted;
SupplementIntake({
this.id,
required this.supplementId,
@@ -13,7 +23,12 @@ class SupplementIntake {
required this.dosageTaken,
required this.unitsTaken,
this.notes,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = SyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
Map<String, dynamic> toMap() {
return {
@@ -23,6 +38,10 @@ class SupplementIntake {
'dosageTaken': dosageTaken,
'unitsTaken': unitsTaken,
'notes': notes,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -34,6 +53,17 @@ class SupplementIntake {
dosageTaken: map['dosageTaken'],
unitsTaken: (map['unitsTaken'] ?? 1).toDouble(), // Default for backwards compatibility
notes: map['notes'],
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? SyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => SyncStatus.pending,
)
: SyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -44,6 +74,10 @@ class SupplementIntake {
double? dosageTaken,
double? unitsTaken,
String? notes,
String? syncId,
DateTime? lastModified,
SyncStatus? syncStatus,
bool? isDeleted,
}) {
return SupplementIntake(
id: id ?? this.id,
@@ -52,6 +86,26 @@ class SupplementIntake {
dosageTaken: dosageTaken ?? this.dosageTaken,
unitsTaken: unitsTaken ?? this.unitsTaken,
notes: notes ?? this.notes,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new intake marked as synced
SupplementIntake markAsSynced() {
return copyWith(
syncStatus: SyncStatus.synced,
);
}
/// Create a new intake marked for deletion
SupplementIntake markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: SyncStatus.modified,
);
}
}

314
lib/models/sync_data.dart Normal file
View File

@@ -0,0 +1,314 @@
import 'dart:convert';
import 'supplement.dart';
import 'supplement_intake.dart';
import 'sync_enums.dart';
/// Model representing the complete sync data structure
/// This is what gets serialized to JSON and synced via WebDAV
class SyncData {
final int version;
final String deviceId;
final String deviceName;
final DateTime syncTimestamp;
final List<Supplement> supplements;
final List<SupplementIntake> intakes;
final Map<String, dynamic> metadata;
const SyncData({
required this.version,
required this.deviceId,
required this.deviceName,
required this.syncTimestamp,
required this.supplements,
required this.intakes,
this.metadata = const {},
});
/// Convert sync data to JSON map
Map<String, dynamic> toJson() {
return {
'version': version,
'deviceId': deviceId,
'deviceName': deviceName,
'syncTimestamp': syncTimestamp.toIso8601String(),
'supplements': supplements.map((s) => s.toMap()).toList(),
'intakes': intakes.map((i) => i.toMap()).toList(),
'metadata': metadata,
};
}
/// Create sync data from JSON map
factory SyncData.fromJson(Map<String, dynamic> json) {
return SyncData(
version: json['version'] ?? 1,
deviceId: json['deviceId'] ?? '',
deviceName: json['deviceName'] ?? 'Unknown Device',
syncTimestamp: json['syncTimestamp'] != null
? DateTime.parse(json['syncTimestamp'])
: DateTime.now(),
supplements: (json['supplements'] as List<dynamic>? ?? [])
.map((s) => Supplement.fromMap(s as Map<String, dynamic>))
.toList(),
intakes: (json['intakes'] as List<dynamic>? ?? [])
.map((i) => SupplementIntake.fromMap(i as Map<String, dynamic>))
.toList(),
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
);
}
/// Convert sync data to JSON string
String toJsonString() {
return jsonEncode(toJson());
}
/// Create sync data from JSON string
factory SyncData.fromJsonString(String jsonString) {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return SyncData.fromJson(json);
}
/// Create a copy with updated values
SyncData copyWith({
int? version,
String? deviceId,
String? deviceName,
DateTime? syncTimestamp,
List<Supplement>? supplements,
List<SupplementIntake>? intakes,
Map<String, dynamic>? metadata,
}) {
return SyncData(
version: version ?? this.version,
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
syncTimestamp: syncTimestamp ?? this.syncTimestamp,
supplements: supplements ?? this.supplements,
intakes: intakes ?? this.intakes,
metadata: metadata ?? this.metadata,
);
}
@override
String toString() {
return 'SyncData(version: $version, deviceId: $deviceId, '
'deviceName: $deviceName, syncTimestamp: $syncTimestamp, '
'supplements: ${supplements.length}, intakes: ${intakes.length})';
}
}
/// Model representing a sync conflict
class SyncConflict {
final String syncId;
final ConflictType type;
final DateTime localTimestamp;
final DateTime remoteTimestamp;
final Map<String, dynamic> localData;
final Map<String, dynamic> remoteData;
final ConflictResolutionStrategy? suggestedResolution;
const SyncConflict({
required this.syncId,
required this.type,
required this.localTimestamp,
required this.remoteTimestamp,
required this.localData,
required this.remoteData,
this.suggestedResolution,
});
Map<String, dynamic> toJson() {
return {
'syncId': syncId,
'type': type.name,
'localTimestamp': localTimestamp.toIso8601String(),
'remoteTimestamp': remoteTimestamp.toIso8601String(),
'localData': localData,
'remoteData': remoteData,
'suggestedResolution': suggestedResolution?.name,
};
}
factory SyncConflict.fromJson(Map<String, dynamic> json) {
return SyncConflict(
syncId: json['syncId'],
type: ConflictType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => ConflictType.modification,
),
localTimestamp: DateTime.parse(json['localTimestamp']),
remoteTimestamp: DateTime.parse(json['remoteTimestamp']),
localData: json['localData'] as Map<String, dynamic>,
remoteData: json['remoteData'] as Map<String, dynamic>,
suggestedResolution: json['suggestedResolution'] != null
? ConflictResolutionStrategy.values.firstWhere(
(e) => e.name == json['suggestedResolution'],
orElse: () => ConflictResolutionStrategy.manual,
)
: null,
);
}
/// Get human-readable description of the conflict
String get description {
switch (type) {
case ConflictType.modification:
return 'Item modified on both devices';
case ConflictType.deletion:
return 'Item deleted on one device, modified on another';
case ConflictType.creation:
return 'Item created with same ID on both devices';
}
}
/// Determine if local version is newer
bool get isLocalNewer => localTimestamp.isAfter(remoteTimestamp);
/// Determine if remote version is newer
bool get isRemoteNewer => remoteTimestamp.isAfter(localTimestamp);
/// Check if timestamps are identical
bool get haveSameTimestamp => localTimestamp.isAtSameMomentAs(remoteTimestamp);
}
/// Types of sync conflicts
enum ConflictType {
/// Both local and remote versions were modified
modification,
/// One version was deleted while the other was modified
deletion,
/// Same item was created on both devices
creation,
}
/// Model for sync operation results
class SyncResult {
final bool success;
final DateTime timestamp;
final SyncOperationStatus status;
final String? error;
final List<SyncConflict> conflicts;
final SyncStatistics statistics;
const SyncResult({
required this.success,
required this.timestamp,
required this.status,
this.error,
this.conflicts = const [],
this.statistics = const SyncStatistics(),
});
Map<String, dynamic> toJson() {
return {
'success': success,
'timestamp': timestamp.toIso8601String(),
'status': status.name,
'error': error,
'conflicts': conflicts.map((c) => c.toJson()).toList(),
'statistics': statistics.toJson(),
};
}
factory SyncResult.fromJson(Map<String, dynamic> json) {
return SyncResult(
success: json['success'] ?? false,
timestamp: DateTime.parse(json['timestamp']),
status: SyncOperationStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => SyncOperationStatus.idle,
),
error: json['error'],
conflicts: (json['conflicts'] as List<dynamic>? ?? [])
.map((c) => SyncConflict.fromJson(c as Map<String, dynamic>))
.toList(),
statistics: json['statistics'] != null
? SyncStatistics.fromJson(json['statistics'] as Map<String, dynamic>)
: const SyncStatistics(),
);
}
/// Create a failed sync result
factory SyncResult.failure({
required String error,
required SyncOperationStatus status,
List<SyncConflict> conflicts = const [],
}) {
return SyncResult(
success: false,
timestamp: DateTime.now(),
status: status,
error: error,
conflicts: conflicts,
);
}
/// Create a successful sync result
factory SyncResult.success({
List<SyncConflict> conflicts = const [],
SyncStatistics statistics = const SyncStatistics(),
}) {
return SyncResult(
success: true,
timestamp: DateTime.now(),
status: conflicts.isEmpty
? SyncOperationStatus.success
: SyncOperationStatus.conflictsDetected,
conflicts: conflicts,
statistics: statistics,
);
}
}
/// Statistics about sync operations
class SyncStatistics {
final int supplementsUploaded;
final int supplementsDownloaded;
final int intakesUploaded;
final int intakesDownloaded;
final int conflictsResolved;
final Duration syncDuration;
const SyncStatistics({
this.supplementsUploaded = 0,
this.supplementsDownloaded = 0,
this.intakesUploaded = 0,
this.intakesDownloaded = 0,
this.conflictsResolved = 0,
this.syncDuration = Duration.zero,
});
Map<String, dynamic> toJson() {
return {
'supplementsUploaded': supplementsUploaded,
'supplementsDownloaded': supplementsDownloaded,
'intakesUploaded': intakesUploaded,
'intakesDownloaded': intakesDownloaded,
'conflictsResolved': conflictsResolved,
'syncDurationMs': syncDuration.inMilliseconds,
};
}
factory SyncStatistics.fromJson(Map<String, dynamic> json) {
return SyncStatistics(
supplementsUploaded: json['supplementsUploaded'] ?? 0,
supplementsDownloaded: json['supplementsDownloaded'] ?? 0,
intakesUploaded: json['intakesUploaded'] ?? 0,
intakesDownloaded: json['intakesDownloaded'] ?? 0,
conflictsResolved: json['conflictsResolved'] ?? 0,
syncDuration: Duration(milliseconds: json['syncDurationMs'] ?? 0),
);
}
int get totalUploaded => supplementsUploaded + intakesUploaded;
int get totalDownloaded => supplementsDownloaded + intakesDownloaded;
int get totalSynced => totalUploaded + totalDownloaded;
@override
String toString() {
return 'SyncStatistics(uploaded: $totalUploaded, downloaded: $totalDownloaded, '
'conflicts: $conflictsResolved, duration: ${syncDuration.inSeconds}s)';
}
}

178
lib/models/sync_enums.dart Normal file
View File

@@ -0,0 +1,178 @@
/// Enumeration for sync status of individual records
enum SyncStatus {
/// Record is newly created and needs to be synced
pending,
/// Record is in sync with remote server
synced,
/// Record has been modified locally and needs to be synced
modified,
/// Record has a conflict that needs resolution
conflict,
/// Record is being synced (temporary state)
syncing,
/// Record sync failed and will be retried
failed,
}
/// Enumeration for overall sync operation status
enum SyncOperationStatus {
/// No sync operation in progress
idle,
/// Currently syncing data
syncing,
/// Last sync completed successfully
success,
/// Last sync failed due to network issues
networkError,
/// Last sync failed due to authentication issues
authenticationError,
/// Last sync failed due to server issues
serverError,
/// Last sync had conflicts that need resolution
conflictsDetected,
/// Sync cancelled by user
cancelled,
}
/// Enumeration for conflict resolution strategies
enum ConflictResolutionStrategy {
/// Always prefer local changes
preferLocal,
/// Always prefer remote changes
preferRemote,
/// Prefer the most recently modified record
preferNewer,
/// Ask user to resolve conflicts manually
manual,
}
/// Enumeration for sync frequency options
enum SyncFrequency {
/// Manual sync only
manual,
/// Sync every 15 minutes
every15Minutes,
/// Sync every hour
hourly,
/// Sync every 6 hours
every6Hours,
/// Sync once per day
daily,
}
/// Extension to get human-readable names for SyncStatus
extension SyncStatusExtension on SyncStatus {
String get displayName {
switch (this) {
case SyncStatus.pending:
return 'Pending Sync';
case SyncStatus.synced:
return 'Synced';
case SyncStatus.modified:
return 'Modified';
case SyncStatus.conflict:
return 'Conflict';
case SyncStatus.syncing:
return 'Syncing...';
case SyncStatus.failed:
return 'Sync Failed';
}
}
}
/// Extension to get human-readable names for SyncOperationStatus
extension SyncOperationStatusExtension on SyncOperationStatus {
String get displayName {
switch (this) {
case SyncOperationStatus.idle:
return 'Ready';
case SyncOperationStatus.syncing:
return 'Syncing...';
case SyncOperationStatus.success:
return 'Sync Complete';
case SyncOperationStatus.networkError:
return 'Network Error';
case SyncOperationStatus.authenticationError:
return 'Authentication Failed';
case SyncOperationStatus.serverError:
return 'Server Error';
case SyncOperationStatus.conflictsDetected:
return 'Conflicts Detected';
case SyncOperationStatus.cancelled:
return 'Sync Cancelled';
}
}
}
/// Extension to get duration for SyncFrequency
extension SyncFrequencyExtension on SyncFrequency {
Duration get duration {
switch (this) {
case SyncFrequency.manual:
return Duration.zero; // Manual sync only
case SyncFrequency.every15Minutes:
return const Duration(minutes: 15);
case SyncFrequency.hourly:
return const Duration(hours: 1);
case SyncFrequency.every6Hours:
return const Duration(hours: 6);
case SyncFrequency.daily:
return const Duration(days: 1);
}
}
String get displayName {
switch (this) {
case SyncFrequency.manual:
return 'Manual Only';
case SyncFrequency.every15Minutes:
return 'Every 15 Minutes';
case SyncFrequency.hourly:
return 'Hourly';
case SyncFrequency.every6Hours:
return 'Every 6 Hours';
case SyncFrequency.daily:
return 'Daily';
}
}
}
/// Constants for sync operations
class SyncConstants {
static const String syncFileName = 'supplements_sync.json';
static const String syncFileBackupName = 'supplements_sync_backup.json';
static const int currentSyncVersion = 1;
static const int maxRetryAttempts = 3;
static const Duration networkTimeout = Duration(seconds: 30);
static const Duration conflictResolutionTimeout = Duration(minutes: 5);
/// Default WebDAV paths for different cloud providers
static const String nextcloudWebdavPath = '/remote.php/dav/files/';
static const String owncloudWebdavPath = '/remote.php/webdav/';
/// Supported cloud providers
static const List<String> supportedProviders = [
'Nextcloud',
'ownCloud',
'Generic WebDAV',
];
}

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import '../services/database_helper.dart';
@@ -19,11 +21,24 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Timer? _dateChangeTimer;
DateTime _lastDateCheck = DateTime.now();
// Callback for triggering sync when data changes
VoidCallback? _onDataChanged;
List<Supplement> get supplements => _supplements;
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
List<Map<String, dynamic>> get monthlyIntakes => _monthlyIntakes;
bool get isLoading => _isLoading;
/// Set callback for triggering sync when data changes
void setOnDataChangedCallback(VoidCallback? callback) {
_onDataChanged = callback;
}
/// Trigger sync if callback is set
void _triggerSyncIfEnabled() {
_onDataChanged?.call();
}
Future<void> initialize() async {
// Add this provider as an observer for app lifecycle changes
WidgetsBinding.instance.addObserver(this);
@@ -235,6 +250,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadSupplements();
print('Supplements reloaded, count: ${_supplements.length}');
// Trigger sync after adding supplement
_triggerSyncIfEnabled();
} catch (e) {
print('Error adding supplement: $e');
if (kDebugMode) {
@@ -252,6 +270,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _notificationService.scheduleSupplementReminders(supplement);
await loadSupplements();
// Trigger sync after updating supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error updating supplement: $e');
@@ -267,6 +288,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _notificationService.cancelSupplementReminders(id);
await loadSupplements();
// Trigger sync after deleting supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting supplement: $e');
@@ -287,6 +311,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.insertIntake(intake);
await loadTodayIntakes();
// Trigger sync after recording intake
_triggerSyncIfEnabled();
// Show confirmation notification
final supplement = _supplements.firstWhere((s) => s.id == supplementId);
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
@@ -356,6 +383,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
// Trigger sync after deleting intake
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting intake: $e');
@@ -425,6 +455,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.archiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after archiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error archiving supplement: $e');
@@ -437,6 +470,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.unarchiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after unarchiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error unarchiving supplement: $e');
@@ -448,6 +484,9 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
await _databaseHelper.deleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after deleting archived supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting archived supplement: $e');

View File

@@ -0,0 +1,417 @@
import 'package:flutter/foundation.dart';
import '../models/sync_data.dart';
import '../models/sync_enums.dart';
import '../services/webdav_sync_service.dart';
import 'supplement_provider.dart';
/// Provider for managing sync operations with WebDAV servers
class SyncProvider with ChangeNotifier {
final WebDAVSyncService _syncService = WebDAVSyncService();
final SupplementProvider _supplementProvider;
SyncOperationStatus _status = SyncOperationStatus.idle;
SyncResult? _lastSyncResult;
DateTime? _lastSyncTime;
String? _currentError;
bool _isAutoSyncEnabled = false;
bool _autoSyncOnDataChanges = true;
SyncFrequency _syncFrequency = SyncFrequency.hourly;
ConflictResolutionStrategy _conflictStrategy = ConflictResolutionStrategy.manual;
List<SyncConflict> _pendingConflicts = [];
double _syncProgress = 0.0;
// WebDAV Configuration
String? _serverUrl;
String? _username;
String? _detectedServerType;
String? _finalWebdavUrl;
String? _syncFolderName;
bool _isConfigured = false;
SyncProvider(this._supplementProvider);
// Getters
SyncOperationStatus get status => _status;
SyncResult? get lastSyncResult => _lastSyncResult;
DateTime? get lastSyncTime => _lastSyncTime;
String? get currentError => _currentError;
bool get isAutoSyncEnabled => _isAutoSyncEnabled;
bool get autoSyncOnDataChanges => _autoSyncOnDataChanges;
SyncFrequency get syncFrequency => _syncFrequency;
ConflictResolutionStrategy get conflictStrategy => _conflictStrategy;
List<SyncConflict> get pendingConflicts => List.unmodifiable(_pendingConflicts);
double get syncProgress => _syncProgress;
String? get serverUrl => _serverUrl;
String? get username => _username;
String? get detectedServerType => _detectedServerType;
String? get finalWebdavUrl => _finalWebdavUrl;
String? get syncFolderName => _syncFolderName;
bool get isConfigured => _isConfigured;
bool get isSyncing => _status == SyncOperationStatus.syncing;
bool get hasError => _currentError != null;
bool get hasPendingConflicts => _pendingConflicts.isNotEmpty;
/// Initialize the sync provider
Future<void> initialize() async {
await _syncService.initialize();
_isConfigured = await _syncService.isConfigured();
_lastSyncTime = await _syncService.getLastSyncTime();
if (_isConfigured) {
// Load existing configuration
_serverUrl = await _syncService.getServerUrl();
_username = await _syncService.getUsername();
_syncFolderName = await _syncService.getSyncFolderName();
_finalWebdavUrl = await _syncService.getLastWorkingUrl();
_detectedServerType = _detectServerTypeFromUrl(_finalWebdavUrl ?? _serverUrl ?? '');
}
// Set up data change callback on supplement provider
_supplementProvider.setOnDataChangedCallback(() {
triggerAutoSyncOnDataChange();
});
notifyListeners();
}
/// Configure WebDAV connection
Future<bool> configure({
required String serverUrl,
required String username,
String? password,
String? deviceName,
String? syncFolderName,
}) async {
try {
_setStatus(SyncOperationStatus.syncing);
_clearError();
final success = await _syncService.configure(
baseUrl: serverUrl,
username: username,
password: password,
deviceName: deviceName,
syncFolderName: syncFolderName ?? 'Supplements',
);
if (success) {
_serverUrl = serverUrl;
_username = username;
_syncFolderName = syncFolderName ?? 'Supplements';
_isConfigured = true;
// Get server detection info
_finalWebdavUrl = await _syncService.getLastWorkingUrl();
_detectedServerType = _detectServerTypeFromUrl(_finalWebdavUrl ?? serverUrl);
_setStatus(SyncOperationStatus.success);
} else {
_setError('Failed to configure WebDAV connection', SyncOperationStatus.authenticationError);
}
return success;
} catch (e) {
_setError(e.toString(), SyncOperationStatus.authenticationError);
return false;
}
}
/// Test WebDAV connection
Future<bool> testConnection() async {
if (!_isConfigured) return false;
try {
_clearError();
return await _syncService.testConnection();
} catch (e) {
_setError(e.toString(), SyncOperationStatus.networkError);
return false;
}
}
/// Clear WebDAV configuration
Future<void> clearConfiguration() async {
await _syncService.clearConfiguration();
_serverUrl = null;
_username = null;
_detectedServerType = null;
_finalWebdavUrl = null;
_syncFolderName = null;
_isConfigured = false;
_setStatus(SyncOperationStatus.idle);
_clearError();
}
/// Perform manual sync
Future<bool> performManualSync() async {
if (!_isConfigured || _status == SyncOperationStatus.syncing) {
return false;
}
try {
_setStatus(SyncOperationStatus.syncing);
_clearError();
_syncProgress = 0.0;
// Check connectivity first
if (!await _syncService.hasConnectivity()) {
_setError('No internet connection', SyncOperationStatus.networkError);
return false;
}
_syncProgress = 0.2;
notifyListeners();
// Perform the sync
final result = await _syncService.performSync();
_lastSyncResult = result;
_lastSyncTime = result.timestamp;
_syncProgress = 0.8;
notifyListeners();
if (result.success) {
if (result.conflicts.isNotEmpty) {
_pendingConflicts = result.conflicts;
_setStatus(SyncOperationStatus.conflictsDetected);
} else {
_setStatus(SyncOperationStatus.success);
}
// Reload local data after successful sync
await _supplementProvider.loadSupplements();
await _supplementProvider.loadTodayIntakes();
} else {
_setError(result.error ?? 'Sync failed', result.status);
}
_syncProgress = 1.0;
notifyListeners();
return result.success;
} catch (e) {
_setError(e.toString(), SyncOperationStatus.serverError);
return false;
}
}
/// Resolve a conflict
Future<void> resolveConflict(String syncId, ConflictResolution resolution) async {
_pendingConflicts.removeWhere((conflict) => conflict.syncId == syncId);
// TODO: Implement actual conflict resolution logic
// This would involve updating the local database with the chosen resolution
if (_pendingConflicts.isEmpty && _status == SyncOperationStatus.conflictsDetected) {
_setStatus(SyncOperationStatus.success);
}
notifyListeners();
}
/// Resolve all conflicts using the configured strategy
Future<void> resolveAllConflicts() async {
if (_conflictStrategy == ConflictResolutionStrategy.manual) {
return; // Manual conflicts need individual resolution
}
final resolvedConflicts = <SyncConflict>[];
for (final conflict in _pendingConflicts) {
ConflictResolution resolution;
switch (_conflictStrategy) {
case ConflictResolutionStrategy.preferLocal:
resolution = ConflictResolution.useLocal;
break;
case ConflictResolutionStrategy.preferRemote:
resolution = ConflictResolution.useRemote;
break;
case ConflictResolutionStrategy.preferNewer:
resolution = conflict.isLocalNewer
? ConflictResolution.useLocal
: ConflictResolution.useRemote;
break;
case ConflictResolutionStrategy.manual:
continue; // Skip manual conflicts
}
await resolveConflict(conflict.syncId, resolution);
resolvedConflicts.add(conflict);
}
if (resolvedConflicts.isNotEmpty) {
// Perform another sync to apply resolutions
await performManualSync();
}
}
/// Enable or disable auto sync
Future<void> setAutoSyncEnabled(bool enabled) async {
_isAutoSyncEnabled = enabled;
notifyListeners();
if (enabled) {
_scheduleNextAutoSync();
}
// TODO: Cancel existing timers when disabled
}
/// Set sync frequency
Future<void> setSyncFrequency(SyncFrequency frequency) async {
_syncFrequency = frequency;
notifyListeners();
if (_isAutoSyncEnabled) {
_scheduleNextAutoSync();
}
}
/// Set conflict resolution strategy
Future<void> setConflictResolutionStrategy(ConflictResolutionStrategy strategy) async {
_conflictStrategy = strategy;
notifyListeners();
}
/// Enable or disable auto sync on data changes
Future<void> setAutoSyncOnDataChanges(bool enabled) async {
_autoSyncOnDataChanges = enabled;
notifyListeners();
}
/// Trigger sync automatically when data changes (if enabled)
Future<void> triggerAutoSyncOnDataChange() async {
if (!_autoSyncOnDataChanges || !_isConfigured || _status == SyncOperationStatus.syncing) {
return;
}
// Perform sync without blocking the UI
performManualSync();
}
/// Get sync statistics from last result
SyncStatistics? get lastSyncStatistics => _lastSyncResult?.statistics;
/// Get formatted last sync time
String get formattedLastSyncTime {
if (_lastSyncTime == null) return 'Never';
final now = DateTime.now();
final difference = now.difference(_lastSyncTime!);
if (difference.inMinutes < 1) {
return 'Just now';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
} else if (difference.inHours < 24) {
return '${difference.inHours}h ago';
} else {
return '${difference.inDays}d ago';
}
}
/// Get sync status message
String get statusMessage {
switch (_status) {
case SyncOperationStatus.idle:
return 'Ready to sync';
case SyncOperationStatus.syncing:
return 'Syncing... ${(_syncProgress * 100).round()}%';
case SyncOperationStatus.success:
return 'Sync completed successfully';
case SyncOperationStatus.networkError:
return 'Network connection failed';
case SyncOperationStatus.authenticationError:
return 'Authentication failed';
case SyncOperationStatus.serverError:
return 'Server error occurred';
case SyncOperationStatus.conflictsDetected:
return '${_pendingConflicts.length} conflict(s) need resolution';
case SyncOperationStatus.cancelled:
return 'Sync was cancelled';
}
}
/// Get device info
Future<Map<String, String?>> getDeviceInfo() async {
return await _syncService.getDeviceInfo();
}
// Private methods
void _setStatus(SyncOperationStatus status) {
_status = status;
notifyListeners();
}
void _setError(String error, SyncOperationStatus status) {
_currentError = error;
_status = status;
_syncProgress = 0.0;
notifyListeners();
}
void _clearError() {
_currentError = null;
notifyListeners();
}
void _scheduleNextAutoSync() {
if (!_isAutoSyncEnabled || _syncFrequency == SyncFrequency.manual) {
return;
}
// TODO: Implement actual scheduling logic using Timer or WorkManager
// This would schedule the next automatic sync based on the frequency
if (kDebugMode) {
print('Next auto sync scheduled for ${_syncFrequency.displayName}');
}
}
/// Detect server type from URL for display purposes
String _detectServerTypeFromUrl(String url) {
final lowerUrl = url.toLowerCase();
if (lowerUrl.contains('/remote.php/dav/files/')) {
return 'Nextcloud';
} else if (lowerUrl.contains('/remote.php/webdav/')) {
return 'ownCloud';
} else if (lowerUrl.contains('nextcloud')) {
return 'Nextcloud';
} else if (lowerUrl.contains('owncloud')) {
return 'ownCloud';
} else if (lowerUrl.contains('/webdav/') || lowerUrl.contains('/dav/')) {
return 'Generic WebDAV';
} else {
return 'WebDAV Server';
}
}
@override
void dispose() {
// TODO: Cancel any pending timers or background tasks
super.dispose();
}
}
/// Enum for conflict resolution choices
enum ConflictResolution {
useLocal,
useRemote,
merge, // For future implementation
}
/// Extension methods for ConflictResolution
extension ConflictResolutionExtension on ConflictResolution {
String get displayName {
switch (this) {
case ConflictResolution.useLocal:
return 'Use Local Version';
case ConflictResolution.useRemote:
return 'Use Remote Version';
case ConflictResolution.merge:
return 'Merge Changes';
}
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/supplement.dart';
import 'package:uuid/uuid.dart';
import '../models/ingredient.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
// Helper class to manage ingredient text controllers
@@ -22,6 +24,8 @@ class IngredientController {
name: nameController.text.trim(),
amount: double.tryParse(amountController.text) ?? 0.0,
unit: selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
);
}
@@ -561,8 +565,10 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
(double.tryParse(controller.amountController.text) ?? 0) > 0
).map((controller) => Ingredient(
name: controller.nameController.text.trim(),
amount: double.tryParse(controller.amountController.text) ?? 0,
amount: double.tryParse(controller.amountController.text) ?? 0.0,
unit: controller.selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
)).toList();
if (validIngredients.isEmpty) {

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
import '../providers/sync_provider.dart';
class ArchivedSupplementsScreen extends StatefulWidget {
const ArchivedSupplementsScreen({super.key});
@@ -25,9 +27,35 @@ class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
appBar: AppBar(
title: const Text('Archived Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status.name == 'success' &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
],
),
body: Consumer<SupplementProvider>(
builder: (context, provider, child) {
body: Consumer2<SupplementProvider, SyncProvider>(
builder: (context, provider, syncProvider, child) {
if (provider.archivedSupplements.isEmpty) {
return Center(
child: Column(

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/sync_provider.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@@ -30,6 +32,32 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar(
title: const Text('Intake History'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status.name == 'success' &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
],
),
body: _buildCalendarView(),
);

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
import 'sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -68,6 +70,22 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Cloud Sync'),
subtitle: const Text('Configure WebDAV sync settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SyncSettingsScreen(),
),
);
},
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/sync_provider.dart';
import '../widgets/supplement_card.dart';
import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart';
@@ -17,6 +19,30 @@ class SupplementsListScreen extends StatelessWidget {
title: const Text('My Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status.name == 'success' &&
DateTime.now().difference(syncProvider.lastSyncTime ?? DateTime.now()).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.performManualSync();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
IconButton(
icon: const Icon(Icons.archive),
onPressed: () {
@@ -30,8 +56,8 @@ class SupplementsListScreen extends StatelessWidget {
),
],
),
body: Consumer2<SupplementProvider, SettingsProvider>(
builder: (context, provider, settingsProvider, child) {
body: Consumer3<SupplementProvider, SettingsProvider, SyncProvider>(
builder: (context, provider, settingsProvider, syncProvider, child) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}

View File

@@ -0,0 +1,782 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/sync_enums.dart';
import '../providers/sync_provider.dart';
/// Screen for configuring WebDAV sync settings
class SyncSettingsScreen extends StatefulWidget {
const SyncSettingsScreen({super.key});
@override
State<SyncSettingsScreen> createState() => _SyncSettingsScreenState();
}
class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _serverUrlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _deviceNameController = TextEditingController();
final _syncFolderController = TextEditingController();
bool _isPasswordVisible = false;
bool _isTestingConnection = false;
bool _isConfiguring = false;
@override
void initState() {
super.initState();
_loadCurrentSettings();
}
void _loadCurrentSettings() {
final syncProvider = context.read<SyncProvider>();
_serverUrlController.text = syncProvider.serverUrl ?? '';
_usernameController.text = syncProvider.username ?? '';
_syncFolderController.text = syncProvider.syncFolderName ?? 'Supplements';
// Note: We don't load the password for security reasons
}
@override
void dispose() {
_serverUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_deviceNameController.dispose();
_syncFolderController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Cloud Sync Settings'),
actions: [
Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) return const SizedBox.shrink();
return PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'test':
_testConnection();
break;
case 'sync':
_performSync();
break;
case 'clear':
_showClearConfigDialog();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'test',
child: ListTile(
leading: Icon(Icons.wifi_protected_setup),
title: Text('Test Connection'),
),
),
const PopupMenuItem(
value: 'sync',
child: ListTile(
leading: Icon(Icons.sync),
title: Text('Sync Now'),
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.clear),
title: Text('Clear Configuration'),
),
),
],
);
},
),
],
),
body: Consumer<SyncProvider>(
builder: (context, syncProvider, child) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusCard(syncProvider),
const SizedBox(height: 24),
_buildDeviceInfoSection(syncProvider),
const SizedBox(height: 24),
_buildConfigurationSection(syncProvider),
const SizedBox(height: 24),
_buildSyncSettingsSection(syncProvider),
if (syncProvider.hasPendingConflicts) ...[
const SizedBox(height: 24),
_buildConflictsSection(syncProvider),
],
],
),
),
);
},
),
);
}
Widget _buildStatusCard(SyncProvider syncProvider) {
Color statusColor;
IconData statusIcon;
switch (syncProvider.status) {
case SyncOperationStatus.success:
statusColor = Colors.green;
statusIcon = Icons.check_circle;
break;
case SyncOperationStatus.syncing:
statusColor = Colors.blue;
statusIcon = Icons.sync;
break;
case SyncOperationStatus.networkError:
case SyncOperationStatus.authenticationError:
case SyncOperationStatus.serverError:
statusColor = Colors.red;
statusIcon = Icons.error;
break;
case SyncOperationStatus.conflictsDetected:
statusColor = Colors.orange;
statusIcon = Icons.warning;
break;
default:
statusColor = Colors.grey;
statusIcon = Icons.cloud_off;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(statusIcon, color: statusColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
syncProvider.statusMessage,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
if (syncProvider.lastSyncTime != null) ...[
const SizedBox(height: 8),
Text(
'Last sync: ${syncProvider.formattedLastSyncTime}',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (syncProvider.isConfigured && syncProvider.detectedServerType != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
const SizedBox(width: 4),
Text(
'Detected: ${syncProvider.detectedServerType}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
],
),
],
if (syncProvider.hasError) ...[
const SizedBox(height: 8),
Text(
syncProvider.currentError!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red,
),
),
],
if (syncProvider.isSyncing) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: syncProvider.syncProgress,
),
],
],
),
),
);
}
Widget _buildConfigurationSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WebDAV Configuration',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'cloud.example.com or drive.mydomain.com',
prefixIcon: Icon(Icons.cloud),
helperText: 'Just enter your server domain - we\'ll auto-detect the rest!',
helperMaxLines: 2,
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter server URL';
}
return null;
},
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline, size: 16, color: Colors.blue[700]),
const SizedBox(width: 8),
Text(
'Smart URL Detection',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.blue[700],
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'You can enter simple URLs like:',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(
'• cloud.example.com\n'
'• drive.mydomain.com\n'
'• nextcloud.company.org\n'
'• my-server.duckdns.org:8080',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
const SizedBox(height: 8),
Text(
'We\'ll automatically detect if it\'s Nextcloud, ownCloud, or generic WebDAV and build the correct URL for you!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter username';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password / App Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
helperText: 'Use app passwords for better security',
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter password';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _syncFolderController,
decoration: const InputDecoration(
labelText: 'Sync Folder Name',
prefixIcon: Icon(Icons.folder),
hintText: 'Supplements',
helperText: 'Folder name on your cloud server for syncing data',
),
validator: (value) {
if (value?.isEmpty ?? true) {
return 'Please enter folder name';
}
if (value!.contains('/') || value.contains('\\')) {
return 'Folder name cannot contain slashes';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _deviceNameController,
decoration: const InputDecoration(
labelText: 'Device Name (Optional)',
prefixIcon: Icon(Icons.phone_android),
hintText: 'My Phone',
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isConfiguring || syncProvider.isSyncing
? null
: () => _configureWebDAV(syncProvider),
icon: _isConfiguring
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(syncProvider.isConfigured ? 'Update' : 'Configure'),
),
),
if (syncProvider.isConfigured) ...[
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _isTestingConnection || syncProvider.isSyncing
? null
: _testConnection,
icon: _isTestingConnection
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_protected_setup),
label: const Text('Test'),
),
],
],
),
],
),
),
);
}
Widget _buildSyncSettingsSection(SyncProvider syncProvider) {
if (!syncProvider.isConfigured) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Auto Sync on Data Changes'),
subtitle: const Text('Automatically sync when you add, modify, or take supplements'),
value: syncProvider.autoSyncOnDataChanges,
onChanged: syncProvider.setAutoSyncOnDataChanges,
),
const SizedBox(height: 8),
ListTile(
title: const Text('Conflict Resolution'),
subtitle: Text(_getConflictStrategyDescription(syncProvider.conflictStrategy)),
trailing: DropdownButton<ConflictResolutionStrategy>(
value: syncProvider.conflictStrategy,
onChanged: (strategy) {
if (strategy != null) {
syncProvider.setConflictResolutionStrategy(strategy);
}
},
items: ConflictResolutionStrategy.values
.map((strategy) => DropdownMenuItem(
value: strategy,
child: Text(_getConflictStrategyName(strategy)),
))
.toList(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: syncProvider.isSyncing ? null : _performSync,
icon: syncProvider.isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
),
],
),
),
);
}
Widget _buildConflictsSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 8),
Text(
'Sync Conflicts',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
Text(
'${syncProvider.pendingConflicts.length} conflicts need your attention',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => _showConflictsDialog(syncProvider),
child: const Text('Review Conflicts'),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: syncProvider.resolveAllConflicts,
child: const Text('Auto Resolve'),
),
],
),
],
),
),
);
}
Widget _buildDeviceInfoSection(SyncProvider syncProvider) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connection Information',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
FutureBuilder<Map<String, String?>>(
future: syncProvider.getDeviceInfo(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final deviceInfo = snapshot.data!;
return Column(
children: [
if (syncProvider.detectedServerType != null)
ListTile(
leading: const Icon(Icons.cloud_done),
title: const Text('Server Type'),
subtitle: Text(syncProvider.detectedServerType!),
),
if (syncProvider.finalWebdavUrl != null)
ListTile(
leading: const Icon(Icons.link),
title: const Text('WebDAV URL'),
subtitle: Text(
syncProvider.finalWebdavUrl!,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
ListTile(
leading: const Icon(Icons.fingerprint),
title: const Text('Device ID'),
subtitle: Text(deviceInfo['deviceId'] ?? 'Unknown'),
),
ListTile(
leading: const Icon(Icons.devices),
title: const Text('Device Name'),
subtitle: Text(deviceInfo['deviceName'] ?? 'Unknown'),
),
],
);
},
),
],
),
),
);
}
String _getConflictStrategyName(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.preferLocal:
return 'Prefer Local';
case ConflictResolutionStrategy.preferRemote:
return 'Prefer Remote';
case ConflictResolutionStrategy.preferNewer:
return 'Prefer Newer';
case ConflictResolutionStrategy.manual:
return 'Manual';
}
}
String _getConflictStrategyDescription(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.preferLocal:
return 'Always keep local changes';
case ConflictResolutionStrategy.preferRemote:
return 'Always keep remote changes';
case ConflictResolutionStrategy.preferNewer:
return 'Keep most recent changes';
case ConflictResolutionStrategy.manual:
return 'Review each conflict manually';
}
}
Future<void> _configureWebDAV(SyncProvider syncProvider) async {
// For updates, allow empty password to keep existing one
if (syncProvider.isConfigured && _passwordController.text.isEmpty) {
// Skip validation for password on updates
if (_serverUrlController.text.trim().isEmpty || _usernameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill in server URL and username'),
backgroundColor: Colors.red,
),
);
return;
}
} else if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _isConfiguring = true);
final success = await syncProvider.configure(
serverUrl: _serverUrlController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text.isEmpty ? null : _passwordController.text,
deviceName: _deviceNameController.text.trim().isEmpty
? null
: _deviceNameController.text.trim(),
syncFolderName: _syncFolderController.text.trim().isEmpty
? 'Supplements'
: _syncFolderController.text.trim(),
);
setState(() => _isConfiguring = false);
if (mounted) {
final message = success
? 'WebDAV configured successfully!'
: 'Failed to configure WebDAV';
final detectionInfo = success && syncProvider.detectedServerType != null
? '\nDetected: ${syncProvider.detectedServerType}'
: '';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$message$detectionInfo'),
backgroundColor: success ? Colors.green : Colors.red,
duration: Duration(seconds: success ? 4 : 3),
),
);
if (success) {
_passwordController.clear(); // Clear password for security
}
}
}
Future<void> _testConnection() async {
setState(() => _isTestingConnection = true);
final syncProvider = context.read<SyncProvider>();
final success = await syncProvider.testConnection();
setState(() => _isTestingConnection = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Connection test successful!'
: 'Connection test failed'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
}
Future<void> _performSync() async {
final syncProvider = context.read<SyncProvider>();
final success = await syncProvider.performManualSync();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Sync completed successfully!'
: 'Sync failed'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
}
Future<void> _showClearConfigDialog() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Configuration'),
content: const Text(
'Are you sure you want to clear the WebDAV configuration? '
'This will disable cloud sync.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Clear'),
),
],
),
);
if (confirmed == true) {
final syncProvider = context.read<SyncProvider>();
await syncProvider.clearConfiguration();
_serverUrlController.clear();
_usernameController.clear();
_passwordController.clear();
_deviceNameController.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Configuration cleared'),
backgroundColor: Colors.orange,
),
);
}
}
}
Future<void> _showConflictsDialog(SyncProvider syncProvider) async {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sync Conflicts'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ListView.builder(
itemCount: syncProvider.pendingConflicts.length,
itemBuilder: (context, index) {
final conflict = syncProvider.pendingConflicts[index];
return Card(
child: ListTile(
title: Text(conflict.type.name),
subtitle: Text(conflict.description),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
syncProvider.resolveConflict(
conflict.syncId,
ConflictResolution.useLocal,
);
},
child: const Text('Local'),
),
TextButton(
onPressed: () {
syncProvider.resolveConflict(
conflict.syncId,
ConflictResolution.useRemote,
);
},
child: const Text('Remote'),
),
],
),
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
}

View File

@@ -1,18 +1,22 @@
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'dart:io';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import '../models/sync_enums.dart';
class DatabaseHelper {
static const _databaseName = 'supplements.db';
static const _databaseVersion = 5; // Increment version for notification tracking
static const _databaseVersion = 6; // Increment version for sync support
static const supplementsTable = 'supplements';
static const intakesTable = 'supplement_intakes';
static const notificationTrackingTable = 'notification_tracking';
static const syncMetadataTable = 'sync_metadata';
static const deviceInfoTable = 'device_info';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
@@ -60,7 +64,11 @@ class DatabaseHelper {
reminderTimes TEXT NOT NULL,
notes TEXT,
createdAt TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 1
isActive INTEGER NOT NULL DEFAULT 1,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0
)
''');
@@ -72,6 +80,10 @@ class DatabaseHelper {
dosageTaken REAL NOT NULL,
unitsTaken REAL NOT NULL DEFAULT 1,
notes TEXT,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
@@ -89,6 +101,27 @@ class DatabaseHelper {
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
// Sync metadata table for tracking sync operations
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Device info table for conflict resolution
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -218,6 +251,77 @@ class DatabaseHelper {
)
''');
}
if (oldVersion < 6) {
// Add sync columns to existing tables
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
// Generate sync IDs and timestamps for existing records
final supplements = await db.query(supplementsTable);
for (final supplement in supplements) {
if (supplement['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
supplementsTable,
{
'syncId': 'sync-${supplement['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [supplement['id']],
);
}
}
final intakes = await db.query(intakesTable);
for (final intake in intakes) {
if (intake['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
intakesTable,
{
'syncId': 'sync-${intake['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [intake['id']],
);
}
}
// Create sync metadata table
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Create device info table
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
}
// Supplement CRUD operations
@@ -230,8 +334,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [1],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [1, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -241,8 +345,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [0],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [0, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -314,8 +418,8 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -328,8 +432,8 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -347,9 +451,9 @@ class DatabaseHelper {
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
''', [startDate, endDate, 0]);
return result;
}
@@ -366,9 +470,9 @@ class DatabaseHelper {
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
''', [startDate, endDate, 0]);
return result;
}
@@ -469,4 +573,130 @@ class DatabaseHelper {
whereArgs: [supplementId],
);
}
// Sync metadata operations
Future<void> setSyncMetadata(String key, String value) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $syncMetadataTable (key, value, lastUpdated)
VALUES (?, ?, ?)
''', [key, value, DateTime.now().toIso8601String()]);
}
Future<String?> getSyncMetadata(String key) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
return result.isNotEmpty ? result.first['value'] : null;
}
Future<void> deleteSyncMetadata(String key) async {
Database db = await database;
await db.delete(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
}
// Device info operations
Future<void> setDeviceInfo(String deviceId, String deviceName) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $deviceInfoTable (deviceId, deviceName, lastSyncTime, createdAt)
VALUES (?, ?, ?, ?)
''', [deviceId, deviceName, null, DateTime.now().toIso8601String()]);
}
Future<void> updateLastSyncTime(String deviceId) async {
Database db = await database;
await db.update(
deviceInfoTable,
{'lastSyncTime': DateTime.now().toIso8601String()},
where: 'deviceId = ?',
whereArgs: [deviceId],
);
}
Future<Map<String, dynamic>?> getDeviceInfo(String deviceId) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
deviceInfoTable,
where: 'deviceId = ?',
whereArgs: [deviceId],
);
return result.isNotEmpty ? result.first : null;
}
// Sync-specific queries
Future<List<Supplement>> getModifiedSupplements() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<List<SupplementIntake>> getModifiedIntakes() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [SyncStatus.pending.name, SyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
}
Future<void> markSupplementAsSynced(String syncId) async {
Database db = await database;
await db.update(
supplementsTable,
{'syncStatus': SyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<void> markIntakeAsSynced(String syncId) async {
Database db = await database;
await db.update(
intakesTable,
{'syncStatus': SyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<Supplement?> getSupplementBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return Supplement.fromMap(maps.first);
}
return null;
}
Future<SupplementIntake?> getIntakeBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return SupplementIntake.fromMap(maps.first);
}
return null;
}
}

View File

@@ -0,0 +1,928 @@
import 'dart:convert';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:webdav_client/webdav_client.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import '../models/sync_data.dart';
import '../models/sync_enums.dart';
import 'database_helper.dart';
/// Service for handling WebDAV synchronization operations
class WebDAVSyncService {
static const String _baseUrlKey = 'webdav_base_url';
static const String _usernameKey = 'webdav_username';
static const String _passwordKey = 'webdav_password';
static const String _deviceNameKey = 'webdav_device_name';
static const String _deviceIdKey = 'webdav_device_id';
static const String _lastSyncTimeKey = 'webdav_last_sync_time';
static const String _lastWorkingUrlKey = 'webdav_last_working_url';
static const String _syncFolderNameKey = 'webdav_sync_folder_name';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
final Connectivity _connectivity = Connectivity();
Client? _webdavClient;
String? _currentDeviceId;
String? _currentDeviceName;
String? _syncFolderName;
/// Initialize the service and generate device ID if needed
Future<void> initialize() async {
await _ensureDeviceInfo();
_syncFolderName = await getSyncFolderName();
}
/// Check if WebDAV is configured
Future<bool> isConfigured() async {
final baseUrl = await _secureStorage.read(key: _baseUrlKey);
final username = await _secureStorage.read(key: _usernameKey);
final password = await _secureStorage.read(key: _passwordKey);
return baseUrl != null &&
baseUrl.isNotEmpty &&
username != null &&
username.isNotEmpty &&
password != null &&
password.isNotEmpty;
}
/// Configure WebDAV connection
Future<bool> configure({
required String baseUrl,
required String username,
String? password,
String? deviceName,
String? syncFolderName,
}) async {
try {
// For updates without password, get existing password
String actualPassword = password ?? '';
if (password == null) {
actualPassword = await _secureStorage.read(key: _passwordKey) ?? '';
if (actualPassword.isEmpty) {
throw Exception('No existing password found and no new password provided');
}
}
// Validate and normalize base URL
final normalizedUrl = _normalizeUrl(baseUrl);
// Build the full WebDAV URL with username for Nextcloud/ownCloud
final fullWebdavUrl = _buildFullWebDAVUrl(normalizedUrl, username);
// Test connection with smart fallback
final testResult = await _testConnectionWithFallback(
fullWebdavUrl,
username,
actualPassword,
baseUrl, // Original URL for fallback attempts
);
if (!testResult.success) {
throw Exception(testResult.errorMessage);
}
final finalUrl = testResult.workingUrl!;
// Save configuration with the working URL
await _secureStorage.write(key: _baseUrlKey, value: finalUrl);
await _secureStorage.write(key: _lastWorkingUrlKey, value: finalUrl);
await _secureStorage.write(key: _usernameKey, value: username);
await _secureStorage.write(key: _passwordKey, value: actualPassword);
if (deviceName != null && deviceName.isNotEmpty) {
await _secureStorage.write(key: _deviceNameKey, value: deviceName);
_currentDeviceName = deviceName;
}
// Store sync folder name
final folderName = syncFolderName ?? 'Supplements';
await _secureStorage.write(key: _syncFolderNameKey, value: folderName);
_syncFolderName = folderName;
// Initialize client with the working URL
_webdavClient = newClient(
finalUrl,
user: username,
password: actualPassword,
debug: kDebugMode,
);
// Ensure sync directory exists
await _ensureSyncDirectoryExists();
if (kDebugMode) {
print('WebDAV configured successfully: $finalUrl');
print('Original URL: $baseUrl -> Detected: $finalUrl');
}
return true;
} catch (e) {
if (kDebugMode) {
print('WebDAV configuration failed: $e');
}
return false;
}
}
/// Remove WebDAV configuration
Future<void> clearConfiguration() async {
await _secureStorage.delete(key: _baseUrlKey);
await _secureStorage.delete(key: _usernameKey);
await _secureStorage.delete(key: _passwordKey);
await _secureStorage.delete(key: _deviceNameKey);
await _secureStorage.delete(key: _lastSyncTimeKey);
await _secureStorage.delete(key: _lastWorkingUrlKey);
await _secureStorage.delete(key: _syncFolderNameKey);
_webdavClient = null;
_syncFolderName = null;
}
/// Test WebDAV connection
Future<bool> testConnection() async {
try {
await _ensureClient();
if (_webdavClient == null) return false;
await _webdavClient!.ping();
return true;
} catch (e) {
if (kDebugMode) {
print('WebDAV connection test failed: $e');
}
return false;
}
}
/// Check if device has internet connectivity
Future<bool> hasConnectivity() async {
final connectivityResult = await _connectivity.checkConnectivity();
return connectivityResult.any((result) =>
result == ConnectivityResult.mobile ||
result == ConnectivityResult.wifi ||
result == ConnectivityResult.ethernet);
}
/// Perform full sync operation
Future<SyncResult> performSync() async {
final syncStartTime = DateTime.now();
try {
// Check connectivity
if (!await hasConnectivity()) {
return SyncResult.failure(
error: 'No internet connection',
status: SyncOperationStatus.networkError,
);
}
// Ensure client is initialized
await _ensureClient();
if (_webdavClient == null) {
return SyncResult.failure(
error: 'WebDAV client not configured',
status: SyncOperationStatus.authenticationError,
);
}
// Download remote data
final remoteSyncData = await _downloadSyncData();
// Get local data that needs syncing
final localSyncData = await _getLocalSyncData();
// Merge data and detect conflicts
final mergeResult = await _mergeData(localSyncData, remoteSyncData);
// Upload merged data back to server
if (mergeResult.hasChanges) {
await _uploadSyncData(mergeResult.mergedData);
}
// Update local sync status
await _updateLocalSyncStatus(mergeResult.mergedData);
// Update last sync time
await _updateLastSyncTime();
final syncDuration = DateTime.now().difference(syncStartTime);
return SyncResult.success(
conflicts: mergeResult.conflicts,
statistics: SyncStatistics(
supplementsUploaded: mergeResult.statistics.supplementsUploaded,
supplementsDownloaded: mergeResult.statistics.supplementsDownloaded,
intakesUploaded: mergeResult.statistics.intakesUploaded,
intakesDownloaded: mergeResult.statistics.intakesDownloaded,
conflictsResolved: mergeResult.statistics.conflictsResolved,
syncDuration: syncDuration,
),
);
} catch (e) {
if (kDebugMode) {
print('Sync operation failed: $e');
}
SyncOperationStatus status = SyncOperationStatus.serverError;
if (e is SocketException || e.toString().contains('network')) {
status = SyncOperationStatus.networkError;
} else if (e.toString().contains('401') || e.toString().contains('403')) {
status = SyncOperationStatus.authenticationError;
}
return SyncResult.failure(
error: e.toString(),
status: status,
);
}
}
/// Get last sync time
Future<DateTime?> getLastSyncTime() async {
final lastSyncStr = await _secureStorage.read(key: _lastSyncTimeKey);
if (lastSyncStr != null) {
return DateTime.tryParse(lastSyncStr);
}
return null;
}
/// Get current device info
Future<Map<String, String?>> getDeviceInfo() async {
await _ensureDeviceInfo();
return {
'deviceId': _currentDeviceId,
'deviceName': _currentDeviceName,
};
}
/// Get the last working WebDAV URL
Future<String?> getLastWorkingUrl() async {
return await _secureStorage.read(key: _lastWorkingUrlKey);
}
/// Get stored server URL
Future<String?> getServerUrl() async {
return await _secureStorage.read(key: _baseUrlKey);
}
/// Get stored username
Future<String?> getUsername() async {
return await _secureStorage.read(key: _usernameKey);
}
/// Get stored sync folder name
Future<String?> getSyncFolderName() async {
final folderName = await _secureStorage.read(key: _syncFolderNameKey);
return folderName ?? 'Supplements';
}
// Private methods
Future<void> _ensureDeviceInfo() async {
_currentDeviceId = await _secureStorage.read(key: _deviceIdKey);
if (_currentDeviceId == null) {
_currentDeviceId = _generateDeviceId();
await _secureStorage.write(key: _deviceIdKey, value: _currentDeviceId!);
}
_currentDeviceName = await _secureStorage.read(key: _deviceNameKey);
if (_currentDeviceName == null) {
_currentDeviceName = _getDefaultDeviceName();
await _secureStorage.write(key: _deviceNameKey, value: _currentDeviceName!);
}
}
String _generateDeviceId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecond;
return 'device_${timestamp}_$random';
}
String _getDefaultDeviceName() {
if (Platform.isAndroid) return 'Android Device';
if (Platform.isIOS) return 'iOS Device';
if (Platform.isWindows) return 'Windows PC';
if (Platform.isMacOS) return 'Mac';
if (Platform.isLinux) return 'Linux PC';
return 'Unknown Device';
}
String _normalizeUrl(String url) {
// Clean and trim the URL
url = url.trim();
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
// Add protocol if missing - try HTTPS first, fallback to HTTP if needed
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
return _detectAndBuildWebDAVUrl(url);
}
/// Advanced server detection and WebDAV URL building
String _detectAndBuildWebDAVUrl(String baseUrl) {
final uri = Uri.tryParse(baseUrl);
if (uri == null) return baseUrl;
final host = uri.host.toLowerCase();
final path = uri.path.toLowerCase();
// If already contains WebDAV path, return as-is
if (path.contains('/remote.php/dav') || path.contains('/remote.php/webdav')) {
return baseUrl;
}
// Nextcloud detection patterns
if (_isNextcloudServer(host, path)) {
return _buildNextcloudUrl(baseUrl, uri);
}
// ownCloud detection patterns
if (_isOwnCloudServer(host, path)) {
return _buildOwnCloudUrl(baseUrl, uri);
}
// Generic WebDAV detection
if (_isGenericWebDAVServer(host, path)) {
return _buildGenericWebDAVUrl(baseUrl, uri);
}
// Default: assume it's a base URL and try Nextcloud first (most common)
return _buildNextcloudUrl(baseUrl, uri);
}
/// Detect if server is likely Nextcloud
bool _isNextcloudServer(String host, String path) {
// Common Nextcloud hosting patterns
final nextcloudPatterns = [
'nextcloud',
'cloud',
'nc.',
'.cloud.',
'files.',
'drive.',
];
// Check hostname patterns
for (final pattern in nextcloudPatterns) {
if (host.contains(pattern)) return true;
}
// Check path patterns
if (path.contains('nextcloud') || path.contains('index.php/apps/files')) {
return true;
}
return false;
}
/// Detect if server is likely ownCloud
bool _isOwnCloudServer(String host, String path) {
final owncloudPatterns = [
'owncloud',
'oc.',
'.owncloud.',
];
// Check hostname patterns
for (final pattern in owncloudPatterns) {
if (host.contains(pattern)) return true;
}
// Check path patterns
if (path.contains('owncloud') || path.contains('index.php/apps/files')) {
return true;
}
return false;
}
/// Detect if server might be generic WebDAV
bool _isGenericWebDAVServer(String host, String path) {
final webdavPatterns = [
'webdav',
'dav',
'caldav',
'carddav',
];
// Check hostname and path for WebDAV indicators
for (final pattern in webdavPatterns) {
if (host.contains(pattern) || path.contains(pattern)) return true;
}
return false;
}
/// Build Nextcloud WebDAV URL with username detection
String _buildNextcloudUrl(String baseUrl, Uri uri) {
String webdavUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
webdavUrl += ':${uri.port}';
}
// Handle subdirectory installations
String basePath = uri.path;
if (basePath.isNotEmpty && basePath != '/') {
// Remove common Nextcloud paths that shouldn't be in base
basePath = basePath.replaceAll(RegExp(r'/index\.php.*$'), '');
basePath = basePath.replaceAll(RegExp(r'/apps/files.*$'), '');
webdavUrl += basePath;
}
// Add Nextcloud WebDAV path - we'll need username later
webdavUrl += '/remote.php/dav/files/';
return webdavUrl;
}
/// Build ownCloud WebDAV URL
String _buildOwnCloudUrl(String baseUrl, Uri uri) {
String webdavUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
webdavUrl += ':${uri.port}';
}
// Handle subdirectory installations
String basePath = uri.path;
if (basePath.isNotEmpty && basePath != '/') {
basePath = basePath.replaceAll(RegExp(r'/index\.php.*$'), '');
basePath = basePath.replaceAll(RegExp(r'/apps/files.*$'), '');
webdavUrl += basePath;
}
webdavUrl += '/remote.php/webdav/';
return webdavUrl;
}
/// Build generic WebDAV URL
String _buildGenericWebDAVUrl(String baseUrl, Uri uri) {
// For generic WebDAV, we assume the URL provided is correct
// but we might need to add common WebDAV paths if missing
if (!uri.path.endsWith('/')) {
return '$baseUrl/';
}
return baseUrl;
}
Future<void> _ensureClient() async {
if (_webdavClient == null) {
final baseUrl = await _secureStorage.read(key: _baseUrlKey);
final username = await _secureStorage.read(key: _usernameKey);
final password = await _secureStorage.read(key: _passwordKey);
if (baseUrl != null && username != null && password != null) {
_webdavClient = newClient(
baseUrl,
user: username,
password: password,
debug: kDebugMode,
);
}
}
}
Future<void> _ensureSyncDirectoryExists() async {
if (_webdavClient == null) return;
// Get the configured folder name, default to 'Supplements'
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
try {
await _webdavClient!.mkdir(folderName);
} catch (e) {
// Directory might already exist, ignore error
if (kDebugMode && !e.toString().contains('409')) {
print('Failed to create sync directory ($folderName): $e');
}
}
}
Future<SyncData?> _downloadSyncData() async {
if (_webdavClient == null) return null;
try {
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
final syncFilePath = '$folderName/${SyncConstants.syncFileName}';
final fileData = await _webdavClient!.read(syncFilePath);
final jsonString = utf8.decode(fileData);
return SyncData.fromJsonString(jsonString);
} catch (e) {
if (kDebugMode) {
print('Failed to download sync data: $e');
}
return null;
}
}
Future<SyncData> _getLocalSyncData() async {
await _ensureDeviceInfo();
final supplements = await _databaseHelper.getAllSupplements();
final archivedSupplements = await _databaseHelper.getArchivedSupplements();
final allSupplements = [...supplements, ...archivedSupplements];
// Get all intakes (we'll need to implement a method to get all intakes)
final intakes = await _getAllIntakes();
return SyncData(
version: SyncConstants.currentSyncVersion,
deviceId: _currentDeviceId!,
deviceName: _currentDeviceName!,
syncTimestamp: DateTime.now(),
supplements: allSupplements,
intakes: intakes,
metadata: {
'totalSupplements': allSupplements.length,
'totalIntakes': intakes.length,
},
);
}
Future<List<SupplementIntake>> _getAllIntakes() async {
// This is a simplified version - in practice, you might want to limit
// to a certain date range or implement pagination for large datasets
final now = DateTime.now();
final oneYearAgo = now.subtract(const Duration(days: 365));
List<SupplementIntake> allIntakes = [];
DateTime current = oneYearAgo;
while (current.isBefore(now)) {
final monthIntakes = await _databaseHelper.getIntakesForMonth(
current.year,
current.month,
);
allIntakes.addAll(monthIntakes);
current = DateTime(current.year, current.month + 1);
}
return allIntakes;
}
Future<_MergeResult> _mergeData(SyncData local, SyncData? remote) async {
if (remote == null) {
// No remote data, just mark local data as pending sync
return _MergeResult(
mergedData: local,
conflicts: [],
hasChanges: true,
statistics: SyncStatistics(
supplementsUploaded: local.supplements.length,
intakesUploaded: local.intakes.length,
),
);
}
final conflicts = <SyncConflict>[];
final mergedSupplements = <String, Supplement>{};
final mergedIntakes = <String, SupplementIntake>{};
// Merge supplements
for (final localSupplement in local.supplements) {
mergedSupplements[localSupplement.syncId] = localSupplement;
}
int supplementsDownloaded = 0;
int supplementsUploaded = local.supplements.length;
for (final remoteSupplement in remote.supplements) {
final localSupplement = mergedSupplements[remoteSupplement.syncId];
if (localSupplement == null) {
// New remote supplement
mergedSupplements[remoteSupplement.syncId] = remoteSupplement;
supplementsDownloaded++;
} else {
// Check for conflicts
if (localSupplement.lastModified.isAfter(remoteSupplement.lastModified)) {
// Local is newer, keep local
continue;
} else if (remoteSupplement.lastModified.isAfter(localSupplement.lastModified)) {
// Remote is newer, use remote
mergedSupplements[remoteSupplement.syncId] = remoteSupplement;
supplementsDownloaded++;
} else {
// Same timestamp - potential conflict if data differs
if (!_supplementsEqual(localSupplement, remoteSupplement)) {
conflicts.add(SyncConflict(
syncId: localSupplement.syncId,
type: ConflictType.modification,
localTimestamp: localSupplement.lastModified,
remoteTimestamp: remoteSupplement.lastModified,
localData: localSupplement.toMap(),
remoteData: remoteSupplement.toMap(),
suggestedResolution: ConflictResolutionStrategy.preferNewer,
));
// For now, keep local version
continue;
}
}
}
}
// Merge intakes (intakes are usually append-only, so fewer conflicts)
for (final localIntake in local.intakes) {
mergedIntakes[localIntake.syncId] = localIntake;
}
int intakesDownloaded = 0;
int intakesUploaded = local.intakes.length;
for (final remoteIntake in remote.intakes) {
if (!mergedIntakes.containsKey(remoteIntake.syncId)) {
mergedIntakes[remoteIntake.syncId] = remoteIntake;
intakesDownloaded++;
}
}
final mergedData = SyncData(
version: SyncConstants.currentSyncVersion,
deviceId: local.deviceId,
deviceName: local.deviceName,
syncTimestamp: DateTime.now(),
supplements: mergedSupplements.values.toList(),
intakes: mergedIntakes.values.toList(),
metadata: {
'mergedAt': DateTime.now().toIso8601String(),
'sourceDevices': [local.deviceId, remote.deviceId],
'conflicts': conflicts.length,
},
);
return _MergeResult(
mergedData: mergedData,
conflicts: conflicts,
hasChanges: supplementsDownloaded > 0 || intakesDownloaded > 0 || conflicts.isNotEmpty,
statistics: SyncStatistics(
supplementsUploaded: supplementsUploaded,
supplementsDownloaded: supplementsDownloaded,
intakesUploaded: intakesUploaded,
intakesDownloaded: intakesDownloaded,
conflictsResolved: 0, // Conflicts are not auto-resolved yet
),
);
}
bool _supplementsEqual(Supplement a, Supplement b) {
return a.name == b.name &&
a.brand == b.brand &&
a.numberOfUnits == b.numberOfUnits &&
a.unitType == b.unitType &&
a.frequencyPerDay == b.frequencyPerDay &&
a.reminderTimes.join(',') == b.reminderTimes.join(',') &&
a.notes == b.notes &&
a.isActive == b.isActive &&
a.isDeleted == b.isDeleted &&
_ingredientsEqual(a.ingredients, b.ingredients);
}
bool _ingredientsEqual(List ingredients1, List ingredients2) {
if (ingredients1.length != ingredients2.length) return false;
for (int i = 0; i < ingredients1.length; i++) {
final ing1 = ingredients1[i];
final ing2 = ingredients2[i];
if (ing1.name != ing2.name ||
ing1.amount != ing2.amount ||
ing1.unit != ing2.unit) {
return false;
}
}
return true;
}
Future<void> _uploadSyncData(SyncData syncData) async {
if (_webdavClient == null) return;
final folderName = _syncFolderName ?? await getSyncFolderName() ?? 'Supplements';
final syncFilePath = '$folderName/${SyncConstants.syncFileName}';
final jsonString = syncData.toJsonString();
final jsonBytes = utf8.encode(jsonString);
// Create backup of existing file first
try {
final backupPath = '$folderName/${SyncConstants.syncFileBackupName}';
final existingData = await _webdavClient!.read(syncFilePath);
await _webdavClient!.write(backupPath, Uint8List.fromList(existingData));
} catch (e) {
// Backup failed, continue anyway
if (kDebugMode) {
print('Failed to create backup: $e');
}
}
// Upload new sync data
await _webdavClient!.write(syncFilePath, jsonBytes);
if (kDebugMode) {
print('Sync data uploaded successfully to $folderName');
}
}
Future<void> _updateLocalSyncStatus(SyncData mergedData) async {
// Mark all synced items as synced in local database
for (final supplement in mergedData.supplements) {
if (supplement.syncStatus != SyncStatus.synced) {
await _databaseHelper.markSupplementAsSynced(supplement.syncId);
}
}
for (final intake in mergedData.intakes) {
if (intake.syncStatus != SyncStatus.synced) {
await _databaseHelper.markIntakeAsSynced(intake.syncId);
}
}
}
Future<void> _updateLastSyncTime() async {
await _secureStorage.write(
key: _lastSyncTimeKey,
value: DateTime.now().toIso8601String(),
);
}
/// Build full WebDAV URL including username for Nextcloud/ownCloud
String _buildFullWebDAVUrl(String baseUrl, String username) {
// If URL ends with /files/ (Nextcloud), add username
if (baseUrl.endsWith('/remote.php/dav/files/')) {
return '$baseUrl$username/';
}
// If URL ends with /webdav/ (ownCloud), it's ready to use
if (baseUrl.endsWith('/remote.php/webdav/')) {
return baseUrl;
}
// For other cases, return as-is
return baseUrl;
}
/// Test connection with smart fallback logic
Future<_ConnectionTestResult> _testConnectionWithFallback(
String primaryUrl,
String username,
String password,
String originalUrl,
) async {
// Try the primary detected URL first
try {
final client = newClient(primaryUrl, user: username, password: password, debug: kDebugMode);
await client.ping();
return _ConnectionTestResult.success(primaryUrl);
} catch (e) {
if (kDebugMode) {
print('Primary URL failed: $primaryUrl - $e');
}
}
// Generate fallback URLs to try
final fallbackUrls = _generateFallbackUrls(originalUrl, username);
for (final fallbackUrl in fallbackUrls) {
try {
final client = newClient(fallbackUrl, user: username, password: password, debug: kDebugMode);
await client.ping();
if (kDebugMode) {
print('Fallback URL succeeded: $fallbackUrl');
}
return _ConnectionTestResult.success(fallbackUrl);
} catch (e) {
if (kDebugMode) {
print('Fallback URL failed: $fallbackUrl - $e');
}
continue;
}
}
return _ConnectionTestResult.failure('Could not connect to WebDAV server. Please check your server URL, username, and password.');
}
/// Generate fallback URLs to try if primary detection fails
List<String> _generateFallbackUrls(String originalUrl, String username) {
final fallbackUrls = <String>[];
final uri = Uri.tryParse(originalUrl.startsWith('http') ? originalUrl : 'https://$originalUrl');
if (uri == null) return fallbackUrls;
String baseUrl = '${uri.scheme}://${uri.host}';
if (uri.hasPort && uri.port != 80 && uri.port != 443) {
baseUrl += ':${uri.port}';
}
// Add path if exists (for subdirectory installations)
String installPath = uri.path;
if (installPath.isNotEmpty && installPath != '/') {
// Clean common paths
installPath = installPath.replaceAll(RegExp(r'/index\.php.*$'), '');
installPath = installPath.replaceAll(RegExp(r'/apps/files.*$'), '');
installPath = installPath.replaceAll(RegExp(r'/remote\.php.*$'), '');
baseUrl += installPath;
}
// Try different combinations
fallbackUrls.addAll([
// Nextcloud variants
'$baseUrl/remote.php/dav/files/$username/',
'$baseUrl/nextcloud/remote.php/dav/files/$username/',
'$baseUrl/cloud/remote.php/dav/files/$username/',
// ownCloud variants
'$baseUrl/remote.php/webdav/',
'$baseUrl/owncloud/remote.php/webdav/',
// Generic WebDAV variants
'$baseUrl/webdav/',
'$baseUrl/dav/',
// Try with different protocols if HTTPS failed
..._generateProtocolVariants(baseUrl, username),
]);
// Remove duplicates and return
return fallbackUrls.toSet().toList();
}
/// Generate protocol variants (HTTP/HTTPS)
List<String> _generateProtocolVariants(String baseUrl, String username) {
if (baseUrl.startsWith('https://')) {
// Try HTTP variants
final httpBase = baseUrl.replaceFirst('https://', 'http://');
return [
'$httpBase/remote.php/dav/files/$username/',
'$httpBase/remote.php/webdav/',
'$httpBase/webdav/',
];
} else if (baseUrl.startsWith('http://')) {
// Try HTTPS variants
final httpsBase = baseUrl.replaceFirst('http://', 'https://');
return [
'$httpsBase/remote.php/dav/files/$username/',
'$httpsBase/remote.php/webdav/',
'$httpsBase/webdav/',
];
}
return [];
}
}
/// Internal class for merge operation results
class _MergeResult {
final SyncData mergedData;
final List<SyncConflict> conflicts;
final bool hasChanges;
final SyncStatistics statistics;
const _MergeResult({
required this.mergedData,
required this.conflicts,
required this.hasChanges,
required this.statistics,
});
}
/// Internal class for connection test results
class _ConnectionTestResult {
final bool success;
final String? workingUrl;
final String? errorMessage;
const _ConnectionTestResult._({
required this.success,
this.workingUrl,
this.errorMessage,
});
factory _ConnectionTestResult.success(String url) {
return _ConnectionTestResult._(success: true, workingUrl: url);
}
factory _ConnectionTestResult.failure(String error) {
return _ConnectionTestResult._(success: false, errorMessage: error);
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,12 +5,18 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import flutter_local_notifications
import flutter_secure_storage_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}

View File

@@ -49,6 +49,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@@ -65,6 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
fake_async:
dependency: transitive
description:
@@ -89,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -134,6 +190,55 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874
url: "https://pub.dev"
source: hosted
version: "10.0.0-beta.4"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845
url: "https://pub.dev"
source: hosted
version: "0.1.0"
flutter_secure_storage_linux:
dependency: "direct overridden"
description:
path: flutter_secure_storage_linux
ref: patch-2
resolved-ref: f076cbb65b075afd6e3b648122987a67306dc298
url: "https://github.com/m-berto/flutter_secure_storage.git"
source: git
version: "2.0.1"
flutter_secure_storage_platform_interface:
dependency: "direct overridden"
description:
name: flutter_secure_storage_platform_interface
sha256: b8337d3d52e429e6c0a7710e38cf9742a3bb05844bd927450eb94f80c11ef85d
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -224,6 +329,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@@ -232,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
path:
dependency: "direct main"
description:
@@ -240,6 +361,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -308,10 +453,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
url: "https://pub.dev"
source: hosted
version: "2.4.11"
version: "2.4.12"
shared_preferences_foundation:
dependency: transitive
description:
@@ -365,6 +510,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
@@ -485,6 +638,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
@@ -509,6 +670,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdav_client:
dependency: "direct main"
description:
name: webdav_client
sha256: "682fffc50b61dc0e8f46717171db03bf9caaa17347be41c0c91e297553bf86b2"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
@@ -527,4 +704,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.29.0"

View File

@@ -1,38 +1,14 @@
name: supplements
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
description: "A supplement tracking app for managing your daily supplements"
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ^3.9.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# Local database for storing supplements data
@@ -53,55 +29,26 @@ dependencies:
# Date time handling
intl: ^0.20.2
# WebDAV sync functionality
webdav_client: ^1.2.2
connectivity_plus: ^6.1.5
flutter_secure_storage: ^10.0.0-beta.4
uuid: ^4.5.1
crypto: ^3.0.6
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
dependency_overrides:
flutter_secure_storage_linux:
git:
url: https://github.com/m-berto/flutter_secure_storage.git
ref: patch-2
path: flutter_secure_storage_linux
flutter_secure_storage_platform_interface: 2.0.0
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@@ -6,6 +6,12 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}

View File

@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST