优化
@@ -14,7 +14,7 @@ A production-ready Flutter quick-start template extracted from real-world projec
|
||||
## Tech Stack
|
||||
|
||||
| Category | Package | Purpose |
|
||||
|---|---|---|
|
||||
| ----------------- | -------------------- | --------------------------------------- |
|
||||
| State Management | flutter_riverpod | Compile-safe, testable state management |
|
||||
| Networking | dio | HTTP client with interceptor chain |
|
||||
| Local Cache | shared_preferences | Key-value persistence |
|
||||
@@ -72,7 +72,7 @@ lib/
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
cd flutter-template
|
||||
cd record-tool
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 依赖 | 用途 |
|
||||
|---|---|---|
|
||||
| -------- | -------------------- | -------------------------- |
|
||||
| 状态管理 | flutter_riverpod | 编译安全、可测试的状态管理 |
|
||||
| 网络请求 | dio | HTTP 客户端,支持拦截器链 |
|
||||
| 本地缓存 | shared_preferences | KV 持久化存储 |
|
||||
@@ -71,7 +71,7 @@ lib/
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd flutter-template
|
||||
cd record-tool
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
|
||||
@@ -5,8 +5,10 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val appPackageName = "com.gdfw.fxjk"
|
||||
|
||||
android {
|
||||
namespace = "com.example.flutter_template"
|
||||
namespace = appPackageName
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -20,8 +22,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.flutter_template"
|
||||
applicationId = appPackageName
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.gdfw.fxjk
|
||||
|
||||
object AppConstants {
|
||||
const val PACKAGE_NAME = "com.gdfw.fxjk"
|
||||
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
||||
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
||||
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
|
||||
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.example.flutter_template
|
||||
package com.gdfw.fxjk
|
||||
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.example.flutter_template.recording.RecordingPlatformHandler
|
||||
import com.example.flutter_template.recording.RecordingPreviewFactory
|
||||
import com.gdfw.fxjk.recording.RecordingPlatformHandler
|
||||
import com.gdfw.fxjk.recording.RecordingPreviewFactory
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -15,7 +15,8 @@ import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.AppConstants
|
||||
import com.gdfw.fxjk.MainActivity
|
||||
|
||||
class RecordingForegroundService : LifecycleService() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@@ -29,7 +30,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
AppConstants.RECORDING_ACTION_START -> {
|
||||
acquireWakeLock()
|
||||
val notification = buildNotification("正在录制")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@@ -43,7 +44,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
AppConstants.RECORDING_ACTION_STOP -> {
|
||||
releaseWakeLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
isRunning = false
|
||||
@@ -143,8 +144,6 @@ class RecordingForegroundService : LifecycleService() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "recording_foreground"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_START = "com.example.flutter_template.recording.START"
|
||||
const val ACTION_STOP = "com.example.flutter_template.recording.STOP"
|
||||
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
|
||||
|
||||
@Volatile
|
||||
@@ -156,7 +155,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
fun start(context: Context) {
|
||||
val intent =
|
||||
Intent(context, RecordingForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
action = AppConstants.RECORDING_ACTION_START
|
||||
}
|
||||
ContextCompatStart.startForegroundService(context, intent)
|
||||
}
|
||||
@@ -164,7 +163,7 @@ class RecordingForegroundService : LifecycleService() {
|
||||
fun stop(context: Context) {
|
||||
val intent =
|
||||
Intent(context, RecordingForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
action = AppConstants.RECORDING_ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
@@ -7,7 +7,8 @@ import android.os.Looper
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.AppConstants
|
||||
import com.gdfw.fxjk.MainActivity
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -18,9 +19,9 @@ class RecordingPlatformHandler(
|
||||
messenger: BinaryMessenger,
|
||||
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
private val methodChannel =
|
||||
MethodChannel(messenger, "com.example.flutter_template/recording")
|
||||
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
|
||||
private val eventChannel =
|
||||
EventChannel(messenger, "com.example.flutter_template/recording_events")
|
||||
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.example.flutter_template.MainActivity
|
||||
import com.gdfw.fxjk.MainActivity
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import io.flutter.plugin.platform.PlatformViewFactory
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleService
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.flutter_template.recording
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
enum class RecordingState {
|
||||
IDLE,
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.1 KiB |
663
android/build/reports/problems/problems-report.html
Normal file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 622 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 945 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.0 KiB |
@@ -410,6 +410,12 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
}
|
||||
|
||||
private enum RecordingChannelNames {
|
||||
static let packageName = "com.gdfw.fxjk"
|
||||
static let method = "\(packageName)/recording"
|
||||
static let events = "\(packageName)/recording_events"
|
||||
}
|
||||
|
||||
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
private let controller = RecordingCameraController.shared
|
||||
private var eventSink: FlutterEventSink?
|
||||
@@ -421,13 +427,13 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
registrar.register(RecordingPreviewFactory(), withId: "recording-camera-preview")
|
||||
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.example.flutter_template/recording",
|
||||
name: RecordingChannelNames.method,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
registrar.addMethodCallDelegate(plugin, channel: methodChannel)
|
||||
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.example.flutter_template/recording_events",
|
||||
name: RecordingChannelNames.events,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
eventChannel.setStreamHandler(plugin)
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/recording/recording_page.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||
import 'package:recording_tool/features/recording/recording_page.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class FlutterTemplateApp extends StatelessWidget {
|
||||
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
||||
const FlutterTemplateApp({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
|
||||
}
|
||||
|
||||
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/app.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppBootstrapper {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/network/api_exception.dart';
|
||||
import 'package:flutter_template/core/network/api_response.dart';
|
||||
import 'package:flutter_template/core/network/http_method.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/core/network/api_response.dart';
|
||||
import 'package:recording_tool/core/network/http_method.dart';
|
||||
|
||||
typedef JsonParser<T> = T Function(dynamic json);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/cache/storage_keys.dart';
|
||||
import 'package:flutter_template/core/utils/device_utils.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||
|
||||
class HeaderInterceptor extends Interceptor {
|
||||
@override
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_template/core/network/network_state.dart';
|
||||
import 'package:recording_tool/core/network/network_state.dart';
|
||||
|
||||
class NetworkMonitor {
|
||||
final _controller = StreamController<NetworkState>.broadcast();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class OfflineQueueInterceptor extends Interceptor {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_template/core/logging/app_logger.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
|
||||
class OfflineQueueManager {
|
||||
OfflineQueueManager({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_template/core/cache/app_storage.dart';
|
||||
import 'package:flutter_template/core/cache/storage_keys.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_request.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_request.dart';
|
||||
|
||||
class OfflineQueueStorage {
|
||||
Future<List<OfflineRequest>> loadQueue() async {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/core/network/api_client.dart';
|
||||
import 'package:flutter_template/core/network/header_interceptor.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_interceptor.dart';
|
||||
import 'package:flutter_template/core/network/providers/network_providers.dart';
|
||||
import 'package:flutter_template/core/network/providers/offline_queue_providers.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/network/api_client.dart';
|
||||
import 'package:recording_tool/core/network/header_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/providers/network_providers.dart';
|
||||
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
|
||||
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = Dio(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/network/network_monitor.dart';
|
||||
import 'package:flutter_template/core/network/network_state.dart';
|
||||
import 'package:recording_tool/core/network/network_monitor.dart';
|
||||
import 'package:recording_tool/core/network/network_state.dart';
|
||||
|
||||
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
||||
final monitor = NetworkMonitor()..start();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:flutter_template/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:flutter_template/core/network/providers/network_providers.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_manager.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_state.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_storage.dart';
|
||||
import 'package:recording_tool/core/network/providers/network_providers.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
final offlineQueueStorageProvider = Provider<OfflineQueueStorage>((ref) {
|
||||
|
||||
44
lib/features/recording/model/model_clipboard.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
/// 剪切板内容数据模型
|
||||
class ClipboardRecordingModel {
|
||||
final String title;
|
||||
final int startTimestamp;
|
||||
final int endTimestamp;
|
||||
final String address;
|
||||
|
||||
ClipboardRecordingModel({
|
||||
required this.title,
|
||||
required this.startTimestamp,
|
||||
required this.endTimestamp,
|
||||
required this.address,
|
||||
});
|
||||
|
||||
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
return ClipboardRecordingModel(
|
||||
title: _readString(json, 'title'),
|
||||
startTimestamp: _readInt(json, 'startTimestamp'),
|
||||
endTimestamp: _readInt(json, 'endTimestamp'),
|
||||
address: _readString(json, 'address'),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'startTimestamp': startTimestamp,
|
||||
'endTimestamp': endTimestamp,
|
||||
'address': address,
|
||||
};
|
||||
}
|
||||
|
||||
static String _readString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String) return value;
|
||||
throw FormatException('Clipboard field "$key" must be a String.');
|
||||
}
|
||||
|
||||
static int _readInt(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is int) return value;
|
||||
throw FormatException('Clipboard field "$key" must be an int.');
|
||||
}
|
||||
}
|
||||
26
lib/features/recording/model/model_recording.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
|
||||
class RecordingModel {
|
||||
/// 剪切板内容
|
||||
final ClipboardRecordingModel clipboardRecordingModel;
|
||||
|
||||
RecordingModel({required this.clipboardRecordingModel});
|
||||
|
||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
|
||||
json['clipboardRecordingModel'],
|
||||
),
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
||||
}
|
||||
|
||||
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel:
|
||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
5
lib/features/recording/recording_channel_names.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
abstract final class RecordingChannelNames {
|
||||
static const packageName = 'com.gdfw.fxjk';
|
||||
static const method = '$packageName/recording';
|
||||
static const events = '$packageName/recording_events';
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:flutter_template/features/recording/recording_session_controller.dart';
|
||||
import 'package:flutter_template/features/recording/widgets/camera_preview_widget.dart';
|
||||
import 'package:flutter_template/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/recording_session_controller.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class RecordingPage extends ConsumerStatefulWidget {
|
||||
@@ -27,6 +28,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
await _enterRecordingMode();
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/recording/recording_channel_names.dart';
|
||||
|
||||
enum RecordingState {
|
||||
idle,
|
||||
@@ -47,10 +48,10 @@ class RecordingPlatform {
|
||||
RecordingPlatform._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'com.example.flutter_template/recording',
|
||||
RecordingChannelNames.method,
|
||||
);
|
||||
static const EventChannel _events = EventChannel(
|
||||
'com.example.flutter_template/recording_events',
|
||||
RecordingChannelNames.events,
|
||||
);
|
||||
|
||||
static bool get isSupported =>
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/permission/permission_service.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class RecordingSessionState {
|
||||
@@ -75,7 +75,7 @@ class RecordingSessionState {
|
||||
final recordingSessionControllerProvider =
|
||||
NotifierProvider<RecordingSessionController, RecordingSessionState>(
|
||||
RecordingSessionController.new,
|
||||
);
|
||||
);
|
||||
|
||||
class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
StreamSubscription<RecordingStatus>? _statusSubscription;
|
||||
@@ -161,9 +161,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
if (!shouldRetry) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(
|
||||
Duration(milliseconds: 150 * (attempt + 1)),
|
||||
);
|
||||
await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw StateError('initializePreview retry exhausted');
|
||||
|
||||
56
lib/features/recording/view-model/view_model_recording.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
|
||||
final recordingViewModelProvider =
|
||||
StateNotifierProvider<RecordingViewModel, RecordingModel>((ref) {
|
||||
return RecordingViewModel(ref);
|
||||
});
|
||||
|
||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
RecordingViewModel(this.ref)
|
||||
: super(
|
||||
RecordingModel(
|
||||
clipboardRecordingModel: ClipboardRecordingModel(
|
||||
title: '',
|
||||
startTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
address: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
final Ref ref;
|
||||
|
||||
/// 从剪切板获取内容
|
||||
Future<void> getClipboardContent() async {
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text;
|
||||
AppLogger.debug('读取剪切板内容:$text');
|
||||
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(text);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
||||
return;
|
||||
}
|
||||
|
||||
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
|
||||
state = state.copyWith(clipboardRecordingModel: clipboardRecordingModel);
|
||||
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
|
||||
} on FormatException catch (error) {
|
||||
AppLogger.warning('剪切板录制信息格式错误:$error');
|
||||
} catch (error, stackTrace) {
|
||||
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import 'package:flutter_template/app/bootstrap.dart';
|
||||
import 'package:recording_tool/app/bootstrap.dart';
|
||||
|
||||
Future<void> main() => AppBootstrapper.bootstrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_network_image.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_network_image.dart';
|
||||
|
||||
class AppAvatar extends StatelessWidget {
|
||||
const AppAvatar({super.key, this.imageUrl, this.initials, this.size = 40});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_button.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_button.dart';
|
||||
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/core/utils/rate_limiter.dart';
|
||||
import 'package:recording_tool/core/utils/rate_limiter.dart';
|
||||
|
||||
class AppSearchBar extends StatefulWidget {
|
||||
const AppSearchBar({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_empty_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_error_view.dart';
|
||||
import 'package:flutter_template/shared/widgets/app_loading_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_error_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
|
||||
|
||||
enum AppViewStatus { loading, empty, error, content }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
|
||||
class AppToast {
|
||||
AppToast._();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: flutter_template
|
||||
description: "A reusable Flutter quick-start template for Android and iOS."
|
||||
name: recording_tool
|
||||
description: "A recording tool for Android and iOS."
|
||||
# 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
|
||||
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
|
||||
@@ -66,7 +66,6 @@ dev_dependencies:
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
|
||||
|
||||
import 'package:flutter_template/core/permission/permission_service.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionService.requestMissing', () {
|
||||
@@ -42,7 +42,8 @@ void main() {
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
|
||||
test('preserves permanently denied permissions without requesting them',
|
||||
test(
|
||||
'preserves permanently denied permissions without requesting them',
|
||||
() async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
@@ -65,14 +66,18 @@ void main() {
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.permanentlyDenied);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera and microphone permission macros', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(podfile, contains("flutter_additional_ios_build_settings(target)"));
|
||||
expect(
|
||||
podfile,
|
||||
contains('flutter_additional_ios_build_settings(target)'),
|
||||
);
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
});
|
||||
|
||||
41
test/features/recording/model_clipboard_test.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
|
||||
void main() {
|
||||
group('ClipboardRecordingModel', () {
|
||||
const clipboardJson = {
|
||||
'title': '王东方 丨李想 空中格斗赛',
|
||||
'startTimestamp': 1717334400,
|
||||
'endTimestamp': 1717334400,
|
||||
'address': '广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
};
|
||||
|
||||
test('parses mini program clipboard JSON', () {
|
||||
final model = ClipboardRecordingModel.fromJson(clipboardJson);
|
||||
|
||||
expect(model.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.startTimestamp, 1717334400);
|
||||
expect(model.endTimestamp, 1717334400);
|
||||
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(model.toJson(), clipboardJson);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('title');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field has wrong type', () {
|
||||
final json = {...clipboardJson, 'startTimestamp': '1717334400'};
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
|
||||
void main() {
|
||||
group('RecordingPlatform support', () {
|
||||
|
||||
148
test/features/recording/view_model_recording_test.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const defaultClipboardTitle = '';
|
||||
const validClipboardText =
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
|
||||
Future<void> setClipboardText(String? text) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return text == null ? null : <String, dynamic>{'text': text};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
group('RecordingViewModel.getClipboardContent', () {
|
||||
test(
|
||||
'updates state when clipboard contains valid mini program JSON',
|
||||
() async {
|
||||
await setClipboardText(validClipboardText);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
final clipboardModel = container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel;
|
||||
expect(clipboardModel.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(clipboardModel.startTimestamp, 1717334400);
|
||||
expect(clipboardModel.endTimestamp, 1717334400);
|
||||
expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps default state when clipboard is empty', () async {
|
||||
await setClipboardText('');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps default state when clipboard is not JSON', () async {
|
||||
await setClipboardText('hello');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps default state when clipboard JSON is not an object', () async {
|
||||
await setClipboardText('[1,2,3]');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default state when clipboard JSON misses required fields',
|
||||
() async {
|
||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'keeps default state when clipboard JSON has wrong field type',
|
||||
() async {
|
||||
await setClipboardText(
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_template/app/app.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('template app renders demo page', (tester) async {
|
||||
testWidgets('recording app renders recording page', (tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Flutter Template'), findsOneWidget);
|
||||
expect(find.text('通用 Flutter 快速开发模板'), findsOneWidget);
|
||||
expect(find.text('增加计数'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.fiber_manual_record), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||