实现 CleanMark AI 的核心功能

前言

图片去水印是 CleanMark AI 的核心功能。用户上传带水印的图片,AI 自动识别并去除水印,返回处理后的图片。

这篇文章我会带你实现完整的图片去水印流程,包括:

  • 图片选择和上传
  • 调用 AI 服务处理
  • 显示处理结果
  • 下载和分享

一、功能流程设计

1.1 业务流程图

用户点击"去水印"
   ↓
选择图片来源(相册/相机)
   ↓
图片选择器
   ↓
预览图片
   ↓
确认上传
   ↓
检查积分(≥1)
   ↓
上传到服务器
   ↓
调用 AI 服务处理
   ↓
显示处理结果
   ↓
下载/分享

请添加图片描述

1.2 数据流

// lib/features/inpaint/models/inpaint_task.dart

/// 去水印任务模型
class InpaintTask {
  final String id;
  final String inputFile;      // 输入文件路径
  final String? outputFile;    // 输出文件路径
  final TaskStatus status;     // 任务状态
  final DateTime createdAt;
  final DateTime? completedAt;

  const InpaintTask({
    required this.id,
    required this.inputFile,
    this.outputFile,
    required this.status,
    required this.createdAt,
    this.completedAt,
  });

  factory InpaintTask.fromJson(Map<String, dynamic> json) {
    return InpaintTask(
      id: json['id'] as String,
      inputFile: json['inputFile'] as String,
      outputFile: json['outputFile'] as String?,
      status: TaskStatus.fromString(json['status'] as String),
      createdAt: DateTime.parse(json['createdAt'] as String),
      completedAt: json['completedAt'] != null
          ? DateTime.parse(json['completedAt'] as String)
          : null,
    );
  }
}

/// 任务状态
enum TaskStatus {
  pending,      // 待处理
  processing,   // 处理中
  completed,    // 已完成
  failed;       // 失败

  static TaskStatus fromString(String status) {
    switch (status.toUpperCase()) {
      case 'PENDING':
        return TaskStatus.pending;
      case 'PROCESSING':
        return TaskStatus.processing;
      case 'DONE':
      case 'COMPLETED':
        return TaskStatus.completed;
      case 'FAILED':
        return TaskStatus.failed;
      default:
        return TaskStatus.pending;
    }
  }
}

二、图片上传页面

2.1 页面 UI

// lib/features/inpaint/presentation/pages/image_upload_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import '../../../../app/app_colors.dart';
import '../../../../shared/widgets/gradient_button.dart';
import '../providers/inpaint_provider.dart';

class ImageUploadPage extends ConsumerStatefulWidget {
  const ImageUploadPage({super.key});

  
  ConsumerState<ImageUploadPage> createState() => _ImageUploadPageState();
}

class _ImageUploadPageState extends ConsumerState<ImageUploadPage> {
  File? _selectedImage;
  final ImagePicker _picker = ImagePicker();

  Future<void> _pickImage(ImageSource source) async {
    try {
      final XFile? image = await _picker.pickImage(
        source: source,
        maxWidth: 2048,
        maxHeight: 2048,
        imageQuality: 85,
      );

      if (image != null) {
        setState(() {
          _selectedImage = File(image.path);
        });
      }
    } catch (e) {
      _showError('选择图片失败: $e');
    }
  }

  Future<void> _uploadAndProcess() async {
    if (_selectedImage == null) {
      _showError('请先选择图片');
      return;
    }

    // 调用 Provider 上传处理
    final result = await ref.read(inpaintProvider.notifier).removeWatermark(
      _selectedImage!,
    );

    if (!mounted) return;

    result.fold(
      (failure) => _showError(failure.message),
      (task) {
        // 跳转到结果页
        Navigator.pushNamed(
          context,
          '/result',
          arguments: task,
        );
      },
    );
  }

  void _showError(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: AppColors.red,
      ),
    );
  }

  
  Widget build(BuildContext context) {
    final inpaintState = ref.watch(inpaintProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('图片去水印'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // 图片预览区域
            _buildImagePreview(),

            const SizedBox(height: 24),

            // 选择图片按钮
            if (_selectedImage == null) ...[
              _buildPickButton(
                icon: Icons.photo_library,
                label: '从相册选择',
                onTap: () => _pickImage(ImageSource.gallery),
              ),
              const SizedBox(height: 12),
              _buildPickButton(
                icon: Icons.camera_alt,
                label: '拍照',
                onTap: () => _pickImage(ImageSource.camera),
              ),
            ],

            // 上传按钮
            if (_selectedImage != null) ...[
              const SizedBox(height: 24),
              inpaintState.isLoading
                  ? const Center(child: CircularProgressIndicator())
                  : GradientButton(
                      text: '开始去水印',
                      onPressed: _uploadAndProcess,
                    ),
              const SizedBox(height: 12),
              TextButton(
                onPressed: () {
                  setState(() {
                    _selectedImage = null;
                  });
                },
                child: const Text('重新选择'),
              ),
            ],

            const SizedBox(height: 24),

            // 提示信息
            _buildTips(),
          ],
        ),
      ),
    );
  }

  Widget _buildImagePreview() {
    return Container(
      height: 300,
      decoration: BoxDecoration(
        color: AppColors.cardDark,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(
          color: AppColors.gray700,
          width: 2,
          style: BorderStyle.solid,
        ),
      ),
      child: _selectedImage != null
          ? ClipRRect(
              borderRadius: BorderRadius.circular(14),
              child: Image.file(
                _selectedImage!,
                fit: BoxFit.contain,
              ),
            )
          : Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(
                    Icons.image_outlined,
                    size: 64,
                    color: AppColors.gray500,
                  ),
                  const SizedBox(height: 16),
                  Text(
                    '请选择要去水印的图片',
                    style: TextStyle(
                      color: AppColors.gray400,
                      fontSize: 16,
                    ),
                  ),
                ],
              ),
            ),
    );
  }

  Widget _buildPickButton({
    required IconData icon,
    required String label,
    required VoidCallback onTap,
  }) {
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(16),
      child: Container(
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: AppColors.cardDark,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: AppColors.gray700),
        ),
        child: Row(
          children: [
            Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: AppColors.purple.withOpacity(0.1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(icon, color: AppColors.purple),
            ),
            const SizedBox(width: 16),
            Text(
              label,
              style: const TextStyle(
                color: AppColors.white,
                fontSize: 16,
                fontWeight: FontWeight.w500,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTips() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: AppColors.blue.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: AppColors.blue.withOpacity(0.3),
        ),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(
                Icons.info_outline,
                color: AppColors.blue,
                size: 20,
              ),
              const SizedBox(width: 8),
              const Text(
                '使用提示',
                style: TextStyle(
                  color: AppColors.blue,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            '• 支持 JPG、PNG 格式\n'
            '• 图片大小不超过 10MB\n'
            '• 每次处理消耗 1 积分\n'
            '• 处理时间约 5-10 秒',
            style: TextStyle(
              color: AppColors.gray400,
              fontSize: 14,
              height: 1.5,
            ),
          ),
        ],
      ),
    );
  }
}

本篇小结(第一部分)

这部分我们完成了:

  1. ✅ 功能流程设计
  2. ✅ 数据模型定义(InpaintTask)
  3. ✅ 图片上传页面 UI

下一部分我会继续讲解状态管理和 API 调用。


三、状态管理

3.1 Inpaint Provider

// lib/features/inpaint/presentation/providers/inpaint_provider.dart

import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/usecases/remove_watermark_usecase.dart';
import '../../models/inpaint_task.dart';

/// Inpaint 状态
class InpaintState {
  final bool isLoading;
  final InpaintTask? currentTask;
  final String? errorMessage;

  const InpaintState({
    this.isLoading = false,
    this.currentTask,
    this.errorMessage,
  });

  InpaintState copyWith({
    bool? isLoading,
    InpaintTask? currentTask,
    String? errorMessage,
  }) {
    return InpaintState(
      isLoading: isLoading ?? this.isLoading,
      currentTask: currentTask ?? this.currentTask,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }
}

/// Inpaint Provider
class InpaintNotifier extends StateNotifier<InpaintState> {
  final RemoveWatermarkUseCase _removeWatermarkUseCase;

  InpaintNotifier(this._removeWatermarkUseCase)
      : super(const InpaintState());

  /// 去除水印
  Future<Either<Failure, InpaintTask>> removeWatermark(File imageFile) async {
    state = state.copyWith(isLoading: true, errorMessage: null);

    final result = await _removeWatermarkUseCase(imageFile);

    return result.fold(
      (failure) {
        state = state.copyWith(
          isLoading: false,
          errorMessage: failure.message,
        );
        return Left(failure);
      },
      (task) {
        state = state.copyWith(
          isLoading: false,
          currentTask: task,
        );
        return Right(task);
      },
    );
  }

  /// 清除当前任务
  void clearCurrentTask() {
    state = state.copyWith(currentTask: null);
  }
}

/// Provider 定义
final inpaintProvider = StateNotifierProvider<InpaintNotifier, InpaintState>(
  (ref) {
    final removeWatermarkUseCase = ref.watch(removeWatermarkUseCaseProvider);
    return InpaintNotifier(removeWatermarkUseCase);
  },
);

3.2 UseCase 实现

// lib/features/inpaint/domain/usecases/remove_watermark_usecase.dart

import 'dart:io';
import 'package:dartz/dartz.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failures.dart';
import '../../models/inpaint_task.dart';
import '../repositories/inpaint_repository.dart';

class RemoveWatermarkUseCase {
  final InpaintRepository _repository;

  RemoveWatermarkUseCase(this._repository);

  Future<Either<Failure, InpaintTask>> call(File imageFile) async {
    // 验证文件
    if (!imageFile.existsSync()) {
      return const Left(ValidationFailure('文件不存在'));
    }

    // 检查文件大小(最大 10MB)
    final fileSize = await imageFile.length();
    if (fileSize > 10 * 1024 * 1024) {
      return const Left(ValidationFailure('文件大小不能超过 10MB'));
    }

    // 检查文件格式
    final extension = imageFile.path.split('.').last.toLowerCase();
    if (!['jpg', 'jpeg', 'png'].contains(extension)) {
      return const Left(ValidationFailure('仅支持 JPG、PNG 格式'));
    }

    // 调用 Repository
    return await _repository.removeWatermark(imageFile);
  }
}

/// Provider 定义
final removeWatermarkUseCaseProvider = Provider<RemoveWatermarkUseCase>(
  (ref) {
    final repository = ref.watch(inpaintRepositoryProvider);
    return RemoveWatermarkUseCase(repository);
  },
);

四、API 服务层

4.1 Repository 接口

// lib/features/inpaint/domain/repositories/inpaint_repository.dart

import 'dart:io';
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../models/inpaint_task.dart';

abstract class InpaintRepository {
  /// 去除水印
  Future<Either<Failure, InpaintTask>> removeWatermark(File imageFile);

  /// 获取任务详情
  Future<Either<Failure, InpaintTask>> getTask(String taskId);

  /// 获取任务列表
  Future<Either<Failure, List<InpaintTask>>> getTasks();
}

4.2 Repository 实现

// lib/features/inpaint/data/repositories/inpaint_repository_impl.dart

import 'dart:io';
import 'package:dartz/dartz.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/repositories/inpaint_repository.dart';
import '../../models/inpaint_task.dart';
import '../datasources/inpaint_remote_datasource.dart';

class InpaintRepositoryImpl implements InpaintRepository {
  final InpaintRemoteDataSource _remoteDataSource;

  InpaintRepositoryImpl(this._remoteDataSource);

  
  Future<Either<Failure, InpaintTask>> removeWatermark(File imageFile) async {
    try {
      final task = await _remoteDataSource.removeWatermark(imageFile);
      return Right(task);
    } on NetworkException catch (e) {
      return Left(NetworkFailure(e.message));
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }

  
  Future<Either<Failure, InpaintTask>> getTask(String taskId) async {
    try {
      final task = await _remoteDataSource.getTask(taskId);
      return Right(task);
    } on NetworkException catch (e) {
      return Left(NetworkFailure(e.message));
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }

  
  Future<Either<Failure, List<InpaintTask>>> getTasks() async {
    try {
      final tasks = await _remoteDataSource.getTasks();
      return Right(tasks);
    } on NetworkException catch (e) {
      return Left(NetworkFailure(e.message));
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } catch (e) {
      return Left(UnknownFailure(e.toString()));
    }
  }
}

/// Provider 定义
final inpaintRepositoryProvider = Provider<InpaintRepository>(
  (ref) {
    final remoteDataSource = ref.watch(inpaintRemoteDataSourceProvider);
    return InpaintRepositoryImpl(remoteDataSource);
  },
);

4.3 Remote DataSource

// lib/features/inpaint/data/datasources/inpaint_remote_datasource.dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_client.dart';
import '../../models/inpaint_task.dart';

class InpaintRemoteDataSource {
  final ApiClient _apiClient;

  InpaintRemoteDataSource(this._apiClient);

  /// 去除水印
  Future<InpaintTask> removeWatermark(File imageFile) async {
    try {
      // 构建 FormData
      final formData = FormData.fromMap({
        'image': await MultipartFile.fromFile(
          imageFile.path,
          filename: imageFile.path.split('/').last,
        ),
      });

      // 发送请求
      final response = await _apiClient.post(
        '/api/inpaint/remove',
        data: formData,
      );

      // 解析响应
      return InpaintTask.fromJson(response.data);
    } on DioException catch (e) {
      if (e.response?.statusCode == 402) {
        throw ServerException('积分不足,请先充值');
      } else if (e.response?.statusCode == 400) {
        throw ServerException(e.response?.data['message'] ?? '请求参数错误');
      } else if (e.type == DioExceptionType.connectionTimeout ||
          e.type == DioExceptionType.receiveTimeout) {
        throw NetworkException('网络连接超时,请检查网络设置');
      } else {
        throw NetworkException('网络请求失败: ${e.message}');
      }
    } catch (e) {
      throw ServerException('未知错误: $e');
    }
  }

  /// 获取任务详情
  Future<InpaintTask> getTask(String taskId) async {
    try {
      final response = await _apiClient.get('/api/inpaint/tasks/$taskId');
      return InpaintTask.fromJson(response.data);
    } on DioException catch (e) {
      throw NetworkException('获取任务详情失败: ${e.message}');
    }
  }

  /// 获取任务列表
  Future<List<InpaintTask>> getTasks() async {
    try {
      final response = await _apiClient.get('/api/inpaint/tasks');
      final List<dynamic> data = response.data as List;
      return data.map((json) => InpaintTask.fromJson(json)).toList();
    } on DioException catch (e) {
      throw NetworkException('获取任务列表失败: ${e.message}');
    }
  }
}

/// Provider 定义
final inpaintRemoteDataSourceProvider = Provider<InpaintRemoteDataSource>(
  (ref) {
    final apiClient = ref.watch(apiClientProvider);
    return InpaintRemoteDataSource(apiClient);
  },
);

/// 异常类
class NetworkException implements Exception {
  final String message;
  NetworkException(this.message);
}

class ServerException implements Exception {
  final String message;
  ServerException(this.message);
}

请添加图片描述


五、结果展示页面

5.1 结果页 UI

// lib/features/inpaint/presentation/pages/result_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io';
import '../../../../app/app_colors.dart';
import '../../../../shared/widgets/gradient_button.dart';
import '../../models/inpaint_task.dart';

class ResultPage extends ConsumerStatefulWidget {
  final InpaintTask task;

  const ResultPage({required this.task, super.key});

  
  ConsumerState<ResultPage> createState() => _ResultPageState();
}

class _ResultPageState extends ConsumerState<ResultPage> {
  bool _showOriginal = false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('处理结果'),
        actions: [
          IconButton(
            icon: const Icon(Icons.share),
            onPressed: _shareImage,
          ),
        ],
      ),
      body: SingleChildScrollView(
        child: Column(
          children: [
            // 对比切换
            _buildComparisonToggle(),

            // 图片展示
            _buildImageDisplay(),

            const SizedBox(height: 24),

            // 操作按钮
            Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  GradientButton(
                    text: '下载图片',
                    onPressed: _downloadImage,
                  ),
                  const SizedBox(height: 12),
                  OutlinedButton.icon(
                    onPressed: () => Navigator.pop(context),
                    icon: const Icon(Icons.home),
                    label: const Text('返回首页'),
                    style: OutlinedButton.styleFrom(
                      foregroundColor: AppColors.white,
                      side: const BorderSide(color: AppColors.gray700),
                      padding: const EdgeInsets.symmetric(vertical: 16),
                    ),
                  ),
                ],
              ),
            ),

            // 提示信息
            _buildTips(),
          ],
        ),
      ),
    );
  }

  Widget _buildComparisonToggle() {
    return Container(
      margin: const EdgeInsets.all(16),
      padding: const EdgeInsets.all(4),
      decoration: BoxDecoration(
        color: AppColors.cardDark,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        children: [
          Expanded(
            child: _buildToggleButton(
              label: '处理后',
              isSelected: !_showOriginal,
              onTap: () => setState(() => _showOriginal = false),
            ),
          ),
          Expanded(
            child: _buildToggleButton(
              label: '原图对比',
              isSelected: _showOriginal,
              onTap: () => setState(() => _showOriginal = true),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildToggleButton({
    required String label,
    required bool isSelected,
    required VoidCallback onTap,
  }) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: isSelected ? AppColors.purple : Colors.transparent,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          label,
          textAlign: TextAlign.center,
          style: TextStyle(
            color: isSelected ? AppColors.white : AppColors.gray400,
            fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
          ),
        ),
      ),
    );
  }

  Widget _buildImageDisplay() {
    if (widget.task.status != TaskStatus.completed) {
      return _buildProcessingState();
    }

    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 16),
      decoration: BoxDecoration(
        color: AppColors.cardDark,
        borderRadius: BorderRadius.circular(16),
      ),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(16),
        child: _showOriginal
            ? _buildComparisonView()
            : _buildResultImage(),
      ),
    );
  }

  Widget _buildProcessingState() {
    return Container(
      margin: const EdgeInsets.all(16),
      padding: const EdgeInsets.all(32),
      decoration: BoxDecoration(
        color: AppColors.cardDark,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: [
          const CircularProgressIndicator(),
          const SizedBox(height: 16),
          Text(
            widget.task.status == TaskStatus.processing
                ? 'AI 正在处理中...'
                : '等待处理',
            style: const TextStyle(
              color: AppColors.gray400,
              fontSize: 16,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildResultImage() {
    if (widget.task.outputFile == null) {
      return const SizedBox();
    }

    // 判断是网络图片还是本地图片
    final isNetwork = widget.task.outputFile!.startsWith('http');

    return isNetwork
        ? Image.network(
            widget.task.outputFile!,
            fit: BoxFit.contain,
            loadingBuilder: (context, child, loadingProgress) {
              if (loadingProgress == null) return child;
              return Center(
                child: CircularProgressIndicator(
                  value: loadingProgress.expectedTotalBytes != null
                      ? loadingProgress.cumulativeBytesLoaded /
                          loadingProgress.expectedTotalBytes!
                      : null,
                ),
              );
            },
          )
        : Image.file(
            File(widget.task.outputFile!),
            fit: BoxFit.contain,
          );
  }

  Widget _buildComparisonView() {
    return Row(
      children: [
        Expanded(
          child: Column(
            children: [
              Container(
                padding: const EdgeInsets.all(8),
                color: AppColors.gray800,
                child: const Text(
                  '原图',
                  style: TextStyle(
                    color: AppColors.white,
                    fontSize: 12,
                  ),
                ),
              ),
              Image.file(
                File(widget.task.inputFile),
                fit: BoxFit.contain,
              ),
            ],
          ),
        ),
        Expanded(
          child: Column(
            children: [
              Container(
                padding: const EdgeInsets.all(8),
                color: AppColors.purple,
                child: const Text(
                  '处理后',
                  style: TextStyle(
                    color: AppColors.white,
                    fontSize: 12,
                  ),
                ),
              ),
              _buildResultImage(),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildTips() {
    return Container(
      margin: const EdgeInsets.all(16),
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: AppColors.green.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: AppColors.green.withOpacity(0.3),
        ),
      ),
      child: Row(
        children: [
          Icon(
            Icons.check_circle_outline,
            color: AppColors.green,
            size: 20,
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              '处理完成!图片已保存到相册',
              style: TextStyle(
                color: AppColors.gray400,
                fontSize: 14,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _downloadImage() async {
    // 下载逻辑
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('图片已保存到相册')),
    );
  }

  Future<void> _shareImage() async {
    // 分享逻辑
  }
}

请添加图片描述


六、下载与分享

6.1 保存到相册

// lib/core/utils/image_saver.dart

import 'dart:io';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:permission_handler/permission_handler.dart';

class ImageSaver {
  /// 保存图片到相册
  static Future<bool> saveToGallery(String imagePath) async {
    try {
      // 请求存储权限
      final status = await Permission.storage.request();
      if (!status.isGranted) {
        return false;
      }

      // 读取图片文件
      final file = File(imagePath);
      if (!file.existsSync()) {
        return false;
      }

      final bytes = await file.readAsBytes();

      // 保存到相册
      final result = await ImageGallerySaver.saveImage(
        bytes,
        quality: 100,
        name: 'cleanmark_${DateTime.now().millisecondsSinceEpoch}',
      );

      return result['isSuccess'] == true;
    } catch (e) {
      print('保存图片失败: $e');
      return false;
    }
  }
}

6.2 分享功能

// lib/core/utils/share_helper.dart

import 'package:share_plus/share_plus.dart';
import 'dart:io';

class ShareHelper {
  /// 分享图片
  static Future<void> shareImage(String imagePath) async {
    try {
      final file = File(imagePath);
      if (!file.existsSync()) {
        throw Exception('文件不存在');
      }

      await Share.shareXFiles(
        [XFile(imagePath)],
        text: '使用 CleanMark AI 去除水印',
      );
    } catch (e) {
      print('分享失败: $e');
      rethrow;
    }
  }
}

七、错误处理

7.1 网络错误处理

// lib/core/errors/failures.dart

abstract class Failure {
  final String message;
  const Failure(this.message);
}

class NetworkFailure extends Failure {
  const NetworkFailure([String message = '网络连接失败,请检查网络设置'])
      : super(message);
}

class ServerFailure extends Failure {
  const ServerFailure(String message) : super(message);
}

class ValidationFailure extends Failure {
  const ValidationFailure(String message) : super(message);
}

class InsufficientCreditsFailure extends Failure {
  const InsufficientCreditsFailure([String message = '积分不足,请先充值'])
      : super(message);
}

class UnknownFailure extends Failure {
  const UnknownFailure(String message) : super(message);
}

7.2 错误提示优化

// lib/shared/widgets/error_dialog.dart

class ErrorDialog extends StatelessWidget {
  final String title;
  final String message;
  final VoidCallback? onRetry;

  const ErrorDialog({
    required this.title,
    required this.message,
    this.onRetry,
    super.key,
  });

  static Future<void> show(
    BuildContext context, {
    required String title,
    required String message,
    VoidCallback? onRetry,
  }) {
    return showDialog(
      context: context,
      builder: (context) => ErrorDialog(
        title: title,
        message: message,
        onRetry: onRetry,
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return AlertDialog(
      backgroundColor: AppColors.cardDark,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      title: Row(
        children: [
          Icon(Icons.error_outline, color: AppColors.red),
          const SizedBox(width: 12),
          Text(
            title,
            style: const TextStyle(color: AppColors.white),
          ),
        ],
      ),
      content: Text(
        message,
        style: const TextStyle(color: AppColors.gray400),
      ),
      actions: [
        if (onRetry != null)
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              onRetry();
            },
            child: const Text('重试'),
          ),
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('确定'),
        ),
      ],
    );
  }
}

八、性能优化

8.1 图片压缩

// lib/core/utils/image_compressor.dart

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

class ImageCompressor {
  /// 压缩图片
  static Future<File?> compress(File file) async {
    try {
      // 获取临时目录
      final tempDir = await getTemporaryDirectory();
      final targetPath = '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}.jpg';

      // 压缩图片
      final result = await FlutterImageCompress.compressAndGetFile(
        file.absolute.path,
        targetPath,
        quality: 85,
        minWidth: 1920,
        minHeight: 1920,
      );

      return result != null ? File(result.path) : null;
    } catch (e) {
      print('压缩图片失败: $e');
      return null;
    }
  }
}

8.2 缓存优化

// 使用 cached_network_image 缓存网络图片
import 'package:cached_network_image/cached_network_image.dart';

Widget _buildResultImage() {
  if (widget.task.outputFile == null) {
    return const SizedBox();
  }

  final isNetwork = widget.task.outputFile!.startsWith('http');

  return isNetwork
      ? CachedNetworkImage(
          imageUrl: widget.task.outputFile!,
          fit: BoxFit.contain,
          placeholder: (context, url) => const Center(
            child: CircularProgressIndicator(),
          ),
          errorWidget: (context, url, error) => const Icon(Icons.error),
        )
      : Image.file(
          File(widget.task.outputFile!),
          fit: BoxFit.contain,
        );
}

8.3 上传进度显示

// lib/features/inpaint/presentation/widgets/upload_progress_dialog.dart

class UploadProgressDialog extends StatelessWidget {
  final double progress;

  const UploadProgressDialog({
    required this.progress,
    super.key,
  });

  static void show(BuildContext context, double progress) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => UploadProgressDialog(progress: progress),
    );
  }

  
  Widget build(BuildContext context) {
    return AlertDialog(
      backgroundColor: AppColors.cardDark,
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          CircularProgressIndicator(value: progress),
          const SizedBox(height: 16),
          Text(
            '上传中... ${(progress * 100).toInt()}%',
            style: const TextStyle(color: AppColors.white),
          ),
        ],
      ),
    );
  }
}

本篇完整小结

这篇文章我们完成了:

  1. ✅ 功能流程设计(选择 → 上传 → 处理 → 展示)
  2. ✅ 数据模型定义(InpaintTask)
  3. ✅ 图片上传页面 UI
  4. ✅ 状态管理(Riverpod Provider)
  5. ✅ UseCase 和 Repository 实现
  6. ✅ API 服务层(Dio + FormData)
  7. ✅ 结果展示页面(对比视图)
  8. ✅ 下载和分享功能
  9. ✅ 错误处理机制
  10. ✅ 性能优化(压缩、缓存)

关键要点:

  • 采用 Clean Architecture 分层架构
  • 使用 Riverpod 管理状态
  • 使用 Either 类型处理错误
  • FormData 上传文件到服务器
  • 提供原图对比功能
  • 图片压缩减少上传时间
  • 使用缓存优化图片加载

API 调用流程:

UI Layer (ImageUploadPage)
   ↓
Provider (InpaintNotifier)
   ↓
UseCase (RemoveWatermarkUseCase)
   ↓
Repository (InpaintRepositoryImpl)
   ↓
DataSource (InpaintRemoteDataSource)
   ↓
API Client (Dio)

错误处理策略:

  1. 网络错误:提示检查网络,提供重试按钮
  2. 服务器错误:显示具体错误信息
  3. 积分不足:引导用户充值
  4. 文件验证:上传前验证格式和大小

思考题

  1. 为什么要使用 Either 类型而不是直接抛出异常?
  2. 如何实现上传进度的实时显示?
  3. 如果处理时间很长(超过 30 秒),如何优化用户体验?

下一篇预告:第10篇 - 视频去水印与进度管理

Logo

一站式 AI 云服务平台

更多推荐