Spaces:
Sleeping
Sleeping
Trae Assistant commited on
Commit ·
de7f206
1
Parent(s): 8d11656
init
Browse files- Dockerfile +12 -0
- README.md +45 -5
- Web/app.py +31 -0
- Web/templates/index.html +92 -0
- lib/logic.dart +76 -0
- lib/main.dart +218 -0
- lib/models.dart +46 -0
- pubspec.yaml +23 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY Web/ .
|
| 6 |
+
|
| 7 |
+
RUN pip install --no-cache-dir flask
|
| 8 |
+
|
| 9 |
+
ENV PORT=7860
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,50 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: TimeVoyager Flutter Showcase
|
| 3 |
+
emoji: 💙
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: cyan
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
short_description: 基于 Flutter 的跨平台资产与时间追踪系统
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# TimeVoyager Flutter
|
| 12 |
+
|
| 13 |
+
TimeVoyager 的 Flutter 版本,旨在通过一套代码库实现 iOS、Android、Web、Windows、macOS 和 Linux 的全平台覆盖。
|
| 14 |
+
|
| 15 |
+
## 核心功能
|
| 16 |
+
|
| 17 |
+
* **跨平台**: 使用 Dart 和 Flutter 构建,UI 与业务逻辑高度复用。
|
| 18 |
+
* **状态管理**: 使用 `Provider` + `ChangeNotifier` 进行高效的状态管理。
|
| 19 |
+
* **资产量化**: 实时计算时间价值,支持多币种。
|
| 20 |
+
* **Material 3**: 遵循最新的 Material Design 3 设计规范。
|
| 21 |
+
|
| 22 |
+
## 目录结构
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
TimeVoyager-Flutter/
|
| 26 |
+
├── lib/
|
| 27 |
+
│ ├── main.dart # 应用入口与UI界面
|
| 28 |
+
│ ├── models.dart # 数据模型 (Project, TimeEntry)
|
| 29 |
+
│ └── logic.dart # 业务逻辑 (TimeManager)
|
| 30 |
+
├── pubspec.yaml # 项目依赖配置
|
| 31 |
+
├── Web/ # Web 演示端 (Flask + Vue)
|
| 32 |
+
├── Dockerfile # Hugging Face 部署配置
|
| 33 |
+
└── README.md
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## 如何运行 Flutter 项目
|
| 37 |
+
|
| 38 |
+
1. 确保已安装 Flutter SDK (3.0+)。
|
| 39 |
+
2. 进入项目目录:`cd TimeVoyager-Flutter`
|
| 40 |
+
3. 获取依赖:`flutter pub get`
|
| 41 |
+
4. 运行:`flutter run`
|
| 42 |
+
|
| 43 |
+
## Web 演示 (Hugging Face)
|
| 44 |
+
|
| 45 |
+
本项目包含一个 Web 演示版本(位于 `Web/` 目录),用于在 Hugging Face Spaces 上展示核心计费逻辑。
|
| 46 |
+
演示版使用 Python Flask 模拟后端计算,前端使用 Vue.js 实现交互,界面风格与 Flutter 版保持一致。
|
| 47 |
+
|
| 48 |
+
## License
|
| 49 |
+
|
| 50 |
+
MIT
|
Web/app.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, jsonify, request
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
app = Flask(__name__)
|
| 5 |
+
|
| 6 |
+
@app.route('/')
|
| 7 |
+
def index():
|
| 8 |
+
return render_template('index.html')
|
| 9 |
+
|
| 10 |
+
@app.route('/api/calculate', methods=['POST'])
|
| 11 |
+
def calculate():
|
| 12 |
+
data = request.json or {}
|
| 13 |
+
rate = float(data.get('hourlyRate', 0))
|
| 14 |
+
duration = float(data.get('duration', 0))
|
| 15 |
+
currency = data.get('currency', 'CNY')
|
| 16 |
+
hours = duration / 3600.0
|
| 17 |
+
earnings = hours * rate
|
| 18 |
+
return jsonify({
|
| 19 |
+
'earnings': round(earnings, 2),
|
| 20 |
+
'currency': currency,
|
| 21 |
+
'hours': round(hours, 4),
|
| 22 |
+
'formatted_earnings': f"{earnings:,.2f} {currency}"
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
@app.route('/health')
|
| 26 |
+
def health():
|
| 27 |
+
return jsonify(status="ok"), 200
|
| 28 |
+
|
| 29 |
+
if __name__ == '__main__':
|
| 30 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 31 |
+
app.run(host='0.0.0.0', port=port)
|
Web/templates/index.html
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>TimeVoyager Flutter 展示</title>
|
| 7 |
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<style>
|
| 10 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
| 11 |
+
</style>
|
| 12 |
+
</head>
|
| 13 |
+
<body class="bg-gray-50 text-gray-900">
|
| 14 |
+
<section class="py-16 container mx-auto px-6">
|
| 15 |
+
<h1 class="text-4xl font-bold text-center mb-6">TimeVoyager Flutter</h1>
|
| 16 |
+
<p class="text-center text-gray-600 mb-8">核心计费逻辑演示 (Dart 移植版)</p>
|
| 17 |
+
<div id="app" class="max-w-md mx-auto bg-white rounded-2xl shadow-xl overflow-hidden">
|
| 18 |
+
<div class="p-8">
|
| 19 |
+
<div class="mb-6">
|
| 20 |
+
<label class="block text-sm font-medium text-gray-700 mb-2">项目名称</label>
|
| 21 |
+
<input type="text" v-model="project.name" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
|
| 22 |
+
</div>
|
| 23 |
+
<div class="flex space-x-4 mb-8">
|
| 24 |
+
<div class="w-1/2">
|
| 25 |
+
<label class="block text-sm font-medium text-gray-700 mb-2">时薪</label>
|
| 26 |
+
<input type="number" v-model.number="project.hourlyRate" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
|
| 27 |
+
</div>
|
| 28 |
+
<div class="w-1/2">
|
| 29 |
+
<label class="block text-sm font-medium text-gray-700 mb-2">货币</label>
|
| 30 |
+
<select v-model="project.currency" class="w-full px-4 py-2 border rounded-lg bg-white">
|
| 31 |
+
<option>USD</option>
|
| 32 |
+
<option>CNY</option>
|
| 33 |
+
<option>EUR</option>
|
| 34 |
+
</select>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="text-center mb-8 bg-gray-50 py-6 rounded-xl border border-gray-200">
|
| 38 |
+
<div class="text-5xl font-mono font-light text-gray-800 mb-2">${ formattedTime }</div>
|
| 39 |
+
<div class="text-2xl font-bold text-green-600">${ earningsDisplay }</div>
|
| 40 |
+
</div>
|
| 41 |
+
<button @click="toggleTimer"
|
| 42 |
+
:class="isRunning ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
| 43 |
+
class="w-full text-white font-bold py-4 rounded-xl transition shadow-lg transform active:scale-95">
|
| 44 |
+
${ isRunning ? '停止计时' : '开始工作' }
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
</section>
|
| 49 |
+
<script>
|
| 50 |
+
const { createApp, ref, computed, onUnmounted } = Vue;
|
| 51 |
+
createApp({
|
| 52 |
+
delimiters: ['${', '}'],
|
| 53 |
+
setup() {
|
| 54 |
+
const project = ref({ name: 'Flutter 跨平台示例', hourlyRate: 150, currency: 'CNY' });
|
| 55 |
+
const isRunning = ref(false);
|
| 56 |
+
const startTime = ref(null);
|
| 57 |
+
const elapsedTime = ref(0);
|
| 58 |
+
const timerInterval = ref(null);
|
| 59 |
+
const earnings = ref(0);
|
| 60 |
+
const formatTime = (seconds) => {
|
| 61 |
+
const h = Math.floor(seconds / 3600);
|
| 62 |
+
const m = Math.floor((seconds % 3600) / 60);
|
| 63 |
+
const s = Math.floor(seconds % 60);
|
| 64 |
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
| 65 |
+
};
|
| 66 |
+
const formattedTime = computed(() => formatTime(elapsedTime.value));
|
| 67 |
+
const earningsDisplay = computed(() => {
|
| 68 |
+
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: project.value.currency }).format(earnings.value);
|
| 69 |
+
});
|
| 70 |
+
const updateTick = () => {
|
| 71 |
+
const now = new Date();
|
| 72 |
+
elapsedTime.value = (now - startTime.value) / 1000;
|
| 73 |
+
const hours = elapsedTime.value / 3600;
|
| 74 |
+
earnings.value = hours * project.value.hourlyRate;
|
| 75 |
+
};
|
| 76 |
+
const toggleTimer = () => {
|
| 77 |
+
if (isRunning.value) {
|
| 78 |
+
clearInterval(timerInterval.value);
|
| 79 |
+
isRunning.value = false;
|
| 80 |
+
} else {
|
| 81 |
+
startTime.value = new Date() - (elapsedTime.value * 1000);
|
| 82 |
+
timerInterval.value = setInterval(updateTick, 100);
|
| 83 |
+
isRunning.value = true;
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
onUnmounted(() => { if (timerInterval.value) clearInterval(timerInterval.value); });
|
| 87 |
+
return { project, isRunning, formattedTime, earningsDisplay, toggleTimer };
|
| 88 |
+
}
|
| 89 |
+
}).mount('#app');
|
| 90 |
+
</script>
|
| 91 |
+
</body>
|
| 92 |
+
</html>
|
lib/logic.dart
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dart:async';
|
| 2 |
+
import 'package:flutter/foundation.dart';
|
| 3 |
+
import 'models.dart';
|
| 4 |
+
|
| 5 |
+
class TimeManager extends ChangeNotifier {
|
| 6 |
+
List<Project> _projects = [];
|
| 7 |
+
List<TimeEntry> _entries = [];
|
| 8 |
+
|
| 9 |
+
TimeEntry? _activeEntry;
|
| 10 |
+
Timer? _timer;
|
| 11 |
+
|
| 12 |
+
// Public Getters
|
| 13 |
+
List<Project> get projects => List.unmodifiable(_projects);
|
| 14 |
+
TimeEntry? get activeEntry => _activeEntry;
|
| 15 |
+
bool get isTracking => _activeEntry != null;
|
| 16 |
+
|
| 17 |
+
Duration get currentDuration => _activeEntry?.duration ?? Duration.zero;
|
| 18 |
+
double get currentEarnings {
|
| 19 |
+
if (_activeEntry == null) return 0.0;
|
| 20 |
+
final project = getProjectById(_activeEntry!.projectId);
|
| 21 |
+
if (project == null) return 0.0;
|
| 22 |
+
return _activeEntry!.calculateEarnings(project.hourlyRate);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
TimeManager() {
|
| 26 |
+
// Demo Data
|
| 27 |
+
addProject(Project(name: '示例项目 Alpha', hourlyRate: 150, currency: 'CNY'));
|
| 28 |
+
addProject(Project(name: '咨询服务 Beta', hourlyRate: 300, currency: 'USD', colorHex: '#4CAF50'));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
void addProject(Project project) {
|
| 32 |
+
_projects.add(project);
|
| 33 |
+
notifyListeners();
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
Project? getProjectById(String id) {
|
| 37 |
+
try {
|
| 38 |
+
return _projects.firstWhere((p) => p.id == id);
|
| 39 |
+
} catch (e) {
|
| 40 |
+
return null;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
void startTracking(Project project) {
|
| 45 |
+
stopTracking(); // Stop current if any
|
| 46 |
+
|
| 47 |
+
_activeEntry = TimeEntry(
|
| 48 |
+
projectId: project.id,
|
| 49 |
+
startTime: DateTime.now(),
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
| 53 |
+
notifyListeners(); // Update UI every second
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
notifyListeners();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
void stopTracking() {
|
| 60 |
+
if (_activeEntry != null) {
|
| 61 |
+
_activeEntry!.endTime = DateTime.now();
|
| 62 |
+
_entries.add(_activeEntry!);
|
| 63 |
+
_activeEntry = null;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
_timer?.cancel();
|
| 67 |
+
_timer = null;
|
| 68 |
+
notifyListeners();
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@override
|
| 72 |
+
void dispose() {
|
| 73 |
+
_timer?.cancel();
|
| 74 |
+
super.dispose();
|
| 75 |
+
}
|
| 76 |
+
}
|
lib/main.dart
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:flutter/material.dart';
|
| 2 |
+
import 'package:provider/provider.dart';
|
| 3 |
+
import 'package:intl/intl.dart';
|
| 4 |
+
import 'models.dart';
|
| 5 |
+
import 'logic.dart';
|
| 6 |
+
|
| 7 |
+
void main() {
|
| 8 |
+
runApp(
|
| 9 |
+
ChangeNotifierProvider(
|
| 10 |
+
create: (context) => TimeManager(),
|
| 11 |
+
child: const TimeVoyagerApp(),
|
| 12 |
+
),
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
class TimeVoyagerApp extends StatelessWidget {
|
| 17 |
+
const TimeVoyagerApp({super.key});
|
| 18 |
+
|
| 19 |
+
@override
|
| 20 |
+
Widget build(BuildContext context) {
|
| 21 |
+
return MaterialApp(
|
| 22 |
+
title: 'TimeVoyager',
|
| 23 |
+
theme: ThemeData(
|
| 24 |
+
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
| 25 |
+
useMaterial3: true,
|
| 26 |
+
),
|
| 27 |
+
home: const ProjectListPage(),
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
class ProjectListPage extends StatelessWidget {
|
| 33 |
+
const ProjectListPage({super.key});
|
| 34 |
+
|
| 35 |
+
@override
|
| 36 |
+
Widget build(BuildContext context) {
|
| 37 |
+
final manager = context.watch<TimeManager>();
|
| 38 |
+
|
| 39 |
+
return Scaffold(
|
| 40 |
+
appBar: AppBar(
|
| 41 |
+
title: const Text('TimeVoyager'),
|
| 42 |
+
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
| 43 |
+
),
|
| 44 |
+
body: ListView.builder(
|
| 45 |
+
itemCount: manager.projects.length,
|
| 46 |
+
itemBuilder: (context, index) {
|
| 47 |
+
final project = manager.projects[index];
|
| 48 |
+
final isActive = manager.activeEntry?.projectId == project.id;
|
| 49 |
+
|
| 50 |
+
return Card(
|
| 51 |
+
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
| 52 |
+
child: ListTile(
|
| 53 |
+
leading: CircleAvatar(
|
| 54 |
+
backgroundColor: isActive ? Colors.green : Colors.blueGrey,
|
| 55 |
+
child: Icon(isActive ? Icons.play_arrow : Icons.folder),
|
| 56 |
+
),
|
| 57 |
+
title: Text(project.name),
|
| 58 |
+
subtitle: Text('${project.hourlyRate} ${project.currency} / 小时'),
|
| 59 |
+
trailing: isActive
|
| 60 |
+
? const Text('正在计时...', style: TextStyle(color: Colors.green))
|
| 61 |
+
: const Icon(Icons.chevron_right),
|
| 62 |
+
onTap: () {
|
| 63 |
+
Navigator.push(
|
| 64 |
+
context,
|
| 65 |
+
MaterialPageRoute(
|
| 66 |
+
builder: (context) => ProjectDetailPage(projectId: project.id),
|
| 67 |
+
),
|
| 68 |
+
);
|
| 69 |
+
},
|
| 70 |
+
),
|
| 71 |
+
);
|
| 72 |
+
},
|
| 73 |
+
),
|
| 74 |
+
floatingActionButton: FloatingActionButton(
|
| 75 |
+
onPressed: () {
|
| 76 |
+
// TODO: Implement Add Project Dialog
|
| 77 |
+
ScaffoldMessenger.of(context).showSnackBar(
|
| 78 |
+
const SnackBar(content: Text('演示模式:默认项目已预置')),
|
| 79 |
+
);
|
| 80 |
+
},
|
| 81 |
+
child: const Icon(Icons.add),
|
| 82 |
+
),
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
class ProjectDetailPage extends StatelessWidget {
|
| 88 |
+
final String projectId;
|
| 89 |
+
|
| 90 |
+
const ProjectDetailPage({super.key, required this.projectId});
|
| 91 |
+
|
| 92 |
+
@override
|
| 93 |
+
Widget build(BuildContext context) {
|
| 94 |
+
final manager = context.watch<TimeManager>();
|
| 95 |
+
final project = manager.getProjectById(projectId);
|
| 96 |
+
|
| 97 |
+
if (project == null) return const Scaffold(body: Center(child: Text('项目不存在')));
|
| 98 |
+
|
| 99 |
+
final isActive = manager.activeEntry?.projectId == project.id;
|
| 100 |
+
|
| 101 |
+
return Scaffold(
|
| 102 |
+
appBar: AppBar(title: Text(project.name)),
|
| 103 |
+
body: Padding(
|
| 104 |
+
padding: const EdgeInsets.all(24.0),
|
| 105 |
+
child: Column(
|
| 106 |
+
children: [
|
| 107 |
+
_buildStatusCard(context, project, manager, isActive),
|
| 108 |
+
const SizedBox(height: 40),
|
| 109 |
+
_buildControlButtons(context, project, manager, isActive),
|
| 110 |
+
],
|
| 111 |
+
),
|
| 112 |
+
),
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
Widget _buildStatusCard(BuildContext context, Project project, TimeManager manager, bool isActive) {
|
| 117 |
+
final duration = isActive ? manager.currentDuration : Duration.zero;
|
| 118 |
+
final earnings = isActive ? manager.currentEarnings : 0.0;
|
| 119 |
+
|
| 120 |
+
final formattedTime = _formatDuration(duration);
|
| 121 |
+
final formattedEarnings = NumberFormat.currency(symbol: project.currency == 'CNY' ? '¥' : '\$')
|
| 122 |
+
.format(earnings);
|
| 123 |
+
|
| 124 |
+
return Container(
|
| 125 |
+
width: double.infinity,
|
| 126 |
+
padding: const EdgeInsets.all(32),
|
| 127 |
+
decoration: BoxDecoration(
|
| 128 |
+
color: isActive ? Colors.blue.shade50 : Colors.grey.shade100,
|
| 129 |
+
borderRadius: BorderRadius.circular(24),
|
| 130 |
+
border: Border.all(
|
| 131 |
+
color: isActive ? Colors.blue.shade200 : Colors.transparent,
|
| 132 |
+
width: 2,
|
| 133 |
+
),
|
| 134 |
+
),
|
| 135 |
+
child: Column(
|
| 136 |
+
children: [
|
| 137 |
+
Text(
|
| 138 |
+
isActive ? '工作中' : '就绪',
|
| 139 |
+
style: TextStyle(
|
| 140 |
+
color: isActive ? Colors.blue : Colors.grey,
|
| 141 |
+
fontWeight: FontWeight.bold,
|
| 142 |
+
letterSpacing: 1.2,
|
| 143 |
+
),
|
| 144 |
+
),
|
| 145 |
+
const SizedBox(height: 16),
|
| 146 |
+
Text(
|
| 147 |
+
formattedTime,
|
| 148 |
+
style: const TextStyle(
|
| 149 |
+
fontSize: 64,
|
| 150 |
+
fontFamily: 'Monospace',
|
| 151 |
+
fontWeight: FontWeight.w300,
|
| 152 |
+
),
|
| 153 |
+
),
|
| 154 |
+
const SizedBox(height: 8),
|
| 155 |
+
Text(
|
| 156 |
+
formattedEarnings,
|
| 157 |
+
style: TextStyle(
|
| 158 |
+
fontSize: 32,
|
| 159 |
+
fontWeight: FontWeight.bold,
|
| 160 |
+
color: isActive ? Colors.green : Colors.grey,
|
| 161 |
+
),
|
| 162 |
+
),
|
| 163 |
+
],
|
| 164 |
+
),
|
| 165 |
+
);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
Widget _buildControlButtons(BuildContext context, Project project, TimeManager manager, bool isActive) {
|
| 169 |
+
if (isActive) {
|
| 170 |
+
return SizedBox(
|
| 171 |
+
width: double.infinity,
|
| 172 |
+
height: 60,
|
| 173 |
+
child: ElevatedButton.icon(
|
| 174 |
+
onPressed: () => manager.stopTracking(),
|
| 175 |
+
style: ElevatedButton.styleFrom(
|
| 176 |
+
backgroundColor: Colors.red.shade100,
|
| 177 |
+
foregroundColor: Colors.red,
|
| 178 |
+
),
|
| 179 |
+
icon: const Icon(Icons.stop_circle),
|
| 180 |
+
label: const Text('停止计时', style: TextStyle(fontSize: 20)),
|
| 181 |
+
),
|
| 182 |
+
);
|
| 183 |
+
} else {
|
| 184 |
+
// Check if another project is running
|
| 185 |
+
final isOtherRunning = manager.isTracking && !isActive;
|
| 186 |
+
|
| 187 |
+
return SizedBox(
|
| 188 |
+
width: double.infinity,
|
| 189 |
+
height: 60,
|
| 190 |
+
child: ElevatedButton.icon(
|
| 191 |
+
onPressed: isOtherRunning
|
| 192 |
+
? () {
|
| 193 |
+
ScaffoldMessenger.of(context).showSnackBar(
|
| 194 |
+
const SnackBar(content: Text('请先停止当前正在进行的项目')),
|
| 195 |
+
);
|
| 196 |
+
}
|
| 197 |
+
: () => manager.startTracking(project),
|
| 198 |
+
style: ElevatedButton.styleFrom(
|
| 199 |
+
backgroundColor: isOtherRunning ? Colors.grey : Colors.green.shade100,
|
| 200 |
+
foregroundColor: isOtherRunning ? Colors.white : Colors.green,
|
| 201 |
+
),
|
| 202 |
+
icon: const Icon(Icons.play_circle),
|
| 203 |
+
label: Text(
|
| 204 |
+
isOtherRunning ? '其他项目进行中' : '开始工作',
|
| 205 |
+
style: const TextStyle(fontSize: 20)
|
| 206 |
+
),
|
| 207 |
+
),
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
String _formatDuration(Duration d) {
|
| 213 |
+
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
| 214 |
+
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(60));
|
| 215 |
+
String twoDigitSeconds = twoDigits(d.inSeconds.remainder(60));
|
| 216 |
+
return "${twoDigits(d.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
|
| 217 |
+
}
|
| 218 |
+
}
|
lib/models.dart
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'package:uuid/uuid.dart';
|
| 2 |
+
|
| 3 |
+
class Project {
|
| 4 |
+
final String id;
|
| 5 |
+
String name;
|
| 6 |
+
double hourlyRate;
|
| 7 |
+
String currency;
|
| 8 |
+
String colorHex;
|
| 9 |
+
DateTime createdAt;
|
| 10 |
+
|
| 11 |
+
Project({
|
| 12 |
+
String? id,
|
| 13 |
+
required this.name,
|
| 14 |
+
this.hourlyRate = 0.0,
|
| 15 |
+
this.currency = 'CNY',
|
| 16 |
+
this.colorHex = '#2196F3',
|
| 17 |
+
DateTime? createdAt,
|
| 18 |
+
}) : id = id ?? const Uuid().v4(),
|
| 19 |
+
createdAt = createdAt ?? DateTime.now();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
class TimeEntry {
|
| 23 |
+
final String id;
|
| 24 |
+
final String projectId;
|
| 25 |
+
DateTime startTime;
|
| 26 |
+
DateTime? endTime;
|
| 27 |
+
String notes;
|
| 28 |
+
|
| 29 |
+
TimeEntry({
|
| 30 |
+
String? id,
|
| 31 |
+
required this.projectId,
|
| 32 |
+
required this.startTime,
|
| 33 |
+
this.endTime,
|
| 34 |
+
this.notes = '',
|
| 35 |
+
}) : id = id ?? const Uuid().v4();
|
| 36 |
+
|
| 37 |
+
Duration get duration {
|
| 38 |
+
final end = endTime ?? DateTime.now();
|
| 39 |
+
return end.difference(startTime);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
double calculateEarnings(double hourlyRate) {
|
| 43 |
+
final hours = duration.inSeconds / 3600.0;
|
| 44 |
+
return hours * hourlyRate;
|
| 45 |
+
}
|
| 46 |
+
}
|
pubspec.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: time_voyager_flutter
|
| 2 |
+
description: A productivity app for tracking time and asset value.
|
| 3 |
+
publish_to: 'none'
|
| 4 |
+
version: 1.0.0+1
|
| 5 |
+
|
| 6 |
+
environment:
|
| 7 |
+
sdk: '>=3.0.0 <4.0.0'
|
| 8 |
+
|
| 9 |
+
dependencies:
|
| 10 |
+
flutter:
|
| 11 |
+
sdk: flutter
|
| 12 |
+
provider: ^6.0.0
|
| 13 |
+
intl: ^0.18.0
|
| 14 |
+
uuid: ^4.0.0
|
| 15 |
+
shared_preferences: ^2.2.0
|
| 16 |
+
|
| 17 |
+
dev_dependencies:
|
| 18 |
+
flutter_test:
|
| 19 |
+
sdk: flutter
|
| 20 |
+
flutter_lints: ^3.0.0
|
| 21 |
+
|
| 22 |
+
flutter:
|
| 23 |
+
uses-material-design: true
|