diff --git a/lib/db/data_provider.dart b/lib/db/data_provider.dart new file mode 100644 index 0000000..7a30880 --- /dev/null +++ b/lib/db/data_provider.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:timemanage/model/project.dart'; +import 'package:timemanage/model/timer_entry.dart'; + +abstract class DataProvider { + Future createProject({required String name, Color? colour}); + Future> listProjects(); + Future editProject(Project project); + Future deleteProject(Project project); + Future createTimer({ + String? description, + int? projectID, + DateTime? startTime, + DateTime? endTime, + }); + Future> listTimers(); + Future editTimer(TimerEntry timer); + Future deleteTimer(TimerEntry timer); + + Future import(DataProvider other) async { + List otherEntries = await other.listTimers(); + List otherProjects = await other.listProjects(); + + List newOtherProjects = await Stream.fromIterable(otherProjects) + .asyncMap((event) => createProject(name: event.name)) + .toList(); + + for (TimerEntry otherEntry in otherEntries) { + int projectOffset = otherProjects + .indexWhere((element) => element.id == otherEntry.projectID); + int? projectID; + if (projectOffset >= 0) { + projectID = newOtherProjects[projectOffset].id; + } + + await createTimer( + description: otherEntry.description, + projectID: projectID, + startTime: otherEntry.startTime, + endTime: otherEntry.endTime, + ); + } + } +} diff --git a/lib/db/database_provider.dart b/lib/db/database_provider.dart new file mode 100644 index 0000000..238c774 --- /dev/null +++ b/lib/db/database_provider.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:timemanage/db/data_provider.dart'; +import 'package:timemanage/model/timer_entry.dart'; +import 'package:timemanage/model/project.dart'; +import 'package:path/path.dart' as p; +import 'package:xdg_directories/xdg_directories.dart'; + +class DatabaseProvider extends DataProvider { + final Database _db; + static const int _dbVersion = 4; + + DatabaseProvider(this._db); + + Future close() async { + await _db.close(); + } + + static void _onConfigure(Database db) async { + await db.execute("PRAGMA foreign_keys = OFF"); + } + + static void _onCreate(Database db, int version) async { + await db.execute(''' + create table if not exists projects( + id integer not null primary key autoincrement, + name text not null, + colour int not null, + archived boolean not null default 0 + ) + '''); + await db.execute(''' + create table if not exists timers( + id integer not null primary key autoincrement, + project_id integer default null, + description text not null, + start_time int not null, + end_time int default null, + notes text default null, + foreign key(project_id) references projects(id) on delete set null + ) + '''); + await db.execute(''' + create index if not exists timers_start_time on timers(start_time) + '''); + } + + static void _onUpgrade(Database db, int version, int newVersion) async { + if (version < 2) { + await db.execute(''' + alter table projects add column archived boolean not null default false + '''); + } + if (version < 3) { + await db.execute(''' + alter table timers add column notes text default null + '''); + } + if (version < 4) { + // fix the bug of the default value being `false` for project archives instead of `0`. + // `false` works fine on sqlite >= 3.23.0. Unfortunately, some Android phones still have + // ancient sqlite versions, so to them `false` is a string rather than an integer with + // value `0` + Batch b = db.batch(); + b.execute(''' + create table projects_tmp( + id integer not null primary key autoincrement, + name text not null, + colour int not null, + archived boolean not null default 0 + ) + '''); + b.execute("insert into projects_tmp select * from projects"); + b.execute("drop table projects"); + b.execute(''' + create table projects( + id integer not null primary key autoincrement, + name text not null, + colour int not null, + archived boolean not null default 0 + ) + '''); + b.execute(''' + insert into projects select id, name, colour, + case archived + when 'false' then 0 + when 'true' then 1 + when '0' then 0 + when '1' then 1 + when 0 then 0 + when 1 then 1 + else 0 + end as archived + from projects_tmp + '''); + b.execute("drop table projects_tmp"); + await b.commit(noResult: true); + } + } + + static Future open(String path) async { + // open the database + Database db = await openDatabase(path, + onConfigure: _onConfigure, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + version: _dbVersion); + await db.execute("PRAGMA foreign_keys = ON"); + DatabaseProvider repo = DatabaseProvider(db); + + return repo; + } + + /// the c in crud + @override + Future createProject( + {required String name, Color? colour, bool? archived}) async { + colour ??= Color.fromARGB(255, 60, 108, 186); + archived ??= false; + + int id = await _db.rawInsert( + "insert into projects(name, colour, archived) values(?, ?, ?)", + [name, colour.value, archived ? 1 : 0]); + return Project(id: id, name: name, colour: colour, archived: archived); + } + + /// the r in crud + @override + Future> listProjects() async { + List> rawProjects = await _db.rawQuery(''' + select id, name, colour, + case archived + when 'false' then 0 + when 'true' then 1 + when '0' then 0 + when '1' then 1 + when 0 then 0 + when 1 then 1 + else 0 + end as archived + from projects order by name asc + '''); + return rawProjects + .map((Map row) => Project( + id: row["id"] as int, + name: row["name"] as String, + colour: Color(row["colour"] as int), + archived: (row["archived"] as int?) == 1)) + .toList(); + } + + /// the u in crud + @override + Future editProject(Project project) async { + int rows = await _db.rawUpdate( + "update projects set name=?, colour=?, archived=? where id=?", + [ + project.name, + project.colour, + project.archived ? 1 : 0, + project.id + ]); + assert(rows == 1); + } + + /// the d in crud + @override + Future deleteProject(Project project) async { + await _db + .rawDelete("delete from projects where id=?", [project.id]); + } + + /// the c in crud + @override + Future createTimer( + {String? description, + int? projectID, + DateTime? startTime, + DateTime? endTime, + String? notes}) async { + int st = startTime?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch; + int? et = endTime?.millisecondsSinceEpoch; + int id = await _db.rawInsert( + "insert into timers(project_id, description, start_time, end_time, notes) values(?, ?, ?, ?, ?)", + [projectID, description, st, et, notes]); + return TimerEntry( + id: id, + description: description, + projectID: projectID, + startTime: DateTime.fromMillisecondsSinceEpoch(st), + endTime: endTime, + notes: notes); + } + + /// the r in crud + @override + Future> listTimers() async { + List> rawTimers = await _db.rawQuery( + "select id, project_id, description, start_time, end_time, notes from timers order by start_time asc"); + return rawTimers + .map((Map row) => TimerEntry( + id: row["id"] as int, + projectID: row["project_id"] as int?, + description: row["description"] as String?, + startTime: + DateTime.fromMillisecondsSinceEpoch(row["start_time"] as int), + endTime: row["end_time"] != null + ? DateTime.fromMillisecondsSinceEpoch(row["end_time"] as int) + : null, + notes: row["notes"] as String?)) + .toList(); + } + + /// the u in crud + @override + Future editTimer(TimerEntry timer) async { + int st = timer.startTime.millisecondsSinceEpoch; + int? et = timer.endTime?.millisecondsSinceEpoch; + await _db.rawUpdate( + "update timers set project_id=?, description=?, start_time=?, end_time=?, notes=? where id=?", + [ + timer.projectID, + timer.description, + st, + et, + timer.notes, + timer.id + ]); + } + + /// the d in crud + @override + Future deleteTimer(TimerEntry timer) async { + await _db.rawDelete("delete from timers where id=?", [timer.id]); + } + + static Future getDatabaseFile() async { + final dbPath = + (Platform.isLinux) ? dataHome.path : await getDatabasesPath(); + return File(p.join(dbPath, 'timecop.db')); + } + + static Future isValidDatabaseFile(String path) async { + try { + Database db = await openDatabase(path, readOnly: true); + await db.rawQuery( + "select id, name, colour, archived from projects order by name asc limit 1"); + await db.rawQuery( + "select id, project_id, description, start_time, end_time, notes from timers order by start_time asc limit 1"); + await db.close(); + return true; + } on Exception catch (_) { + return false; + } + } +} diff --git a/lib/db/mock_data_provider.dart b/lib/db/mock_data_provider.dart new file mode 100644 index 0000000..9e3efcd --- /dev/null +++ b/lib/db/mock_data_provider.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:timemanage/db/data_provider.dart'; +import 'package:timemanage/model/project.dart'; +import 'package:timemanage/model/timer_entry.dart'; +import 'dart:math'; + +class MockDataProvider extends DataProvider { + String localeKey; + static final Map> l10n = { + "en": { + "administration": "Administration", + "mockups": "Mockups", + "ui-layout": "UI Layout", + "coffee": "Coffee", + "app-development": "App development" + }, + "zh-CN": { + "ui-layout": "UI布局", + "administration": "管理", + "coffee": "咖啡", + "mockups": "样机", + "app-development": "应用程式开发", + }, + }; + + MockDataProvider(Locale locale) : localeKey = locale.languageCode { + if (locale.languageCode == "zh") { + localeKey += "-${locale.countryCode!}"; + } + } + + @override + Future> listProjects() async { + return [ + Project( + id: 1, + name: "Time Manager", + colour: Colors.cyan[600]!, + archived: false), + Project( + id: 2, + name: l10n[localeKey]!["administration"]!, + colour: Colors.pink[600]!, + archived: false, + ), + ]; + } + + @override + Future> listTimers() async { + int tid = 1; + Random rand = Random(42); + + // start with running timers + List entries = [ + TimerEntry( + id: tid++, + description: l10n[localeKey]!["ui-layout"], + projectID: 1, + startTime: DateTime.now() + .subtract(const Duration(hours: 2, minutes: 10, seconds: 1)), + endTime: null, + ), + TimerEntry( + id: tid++, + description: l10n[localeKey]!["coffee"], + projectID: 2, + startTime: + DateTime.now().subtract(const Duration(minutes: 3, seconds: 14)), + endTime: null, + ), + ]; + + // add some fake March stuff + for (int w = 0; w < 4; w++) { + for (int d = 0; d < 5; d++) { + String descriptionKey; + double r = rand.nextDouble(); + if (r <= 0.2) { + descriptionKey = 'mockups'; + } else if (r <= 0.5) { + descriptionKey = 'ui-layout'; + } else { + descriptionKey = 'app-development'; + } + + entries.add(TimerEntry( + id: tid++, + description: l10n[localeKey]![descriptionKey], + projectID: 1, + startTime: DateTime( + 2020, + 3, + (w * 7) + d + 2, + rand.nextInt(3) + 8, + rand.nextInt(60), + rand.nextInt(60), + ), + endTime: DateTime( + 2020, + 3, + (w * 7) + d + 2, + rand.nextInt(3) + 13, + rand.nextInt(60), + rand.nextInt(60), + ), + )); + + entries.add(TimerEntry( + id: tid++, + description: l10n[localeKey]!['administration'], + projectID: 2, + startTime: DateTime( + 2020, + 3, + (w * 7) + d + 2, + 14, + rand.nextInt(30), + rand.nextInt(60), + ), + endTime: DateTime( + 2020, + 3, + (w * 7) + d + 2, + 15, + rand.nextInt(30), + rand.nextInt(60), + ), + )); + } + } + return entries; + } + + @override + Future createProject( + {required String name, Color? colour, bool? archived}) async { + return Project( + id: -1, name: name, colour: colour!, archived: archived ?? false); + } + + @override + Future editProject(Project project) async {} + @override + Future deleteProject(Project project) async {} + @override + Future createTimer( + {String? description, + int? projectID, + DateTime? startTime, + DateTime? endTime}) async { + DateTime st = startTime ?? DateTime.now(); + return TimerEntry( + id: -1, + description: description, + projectID: projectID, + startTime: st, + endTime: endTime, + ); + } + + @override + Future editTimer(TimerEntry timer) async {} + @override + Future deleteTimer(TimerEntry timer) async {} +} diff --git a/lib/model/project.dart b/lib/model/project.dart index b18fd4e..edf4ce4 100644 --- a/lib/model/project.dart +++ b/lib/model/project.dart @@ -4,12 +4,14 @@ import 'package:equatable/equatable.dart'; // 比较两个对象是否相等的 class Project extends Equatable { final int id; final String name; + final Color? colour; // 颜色 final bool archived; // 是否归档 // 构造函数 const Project({ required this.id, required this.name, + required this.colour, required this.archived, }); @@ -24,6 +26,7 @@ class Project extends Equatable { }) : this( id: project.id, name: name ?? project.name, + colour: color ?? project.colour, archived: archived ?? project.archived, ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 744bfe9..97ce740 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import package_info_plus +import sqflite import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 7207144..a185836 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -333,6 +333,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" stack_trace: dependency: transitive description: @@ -357,6 +373,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" term_glyph: dependency: transitive description: @@ -493,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.8" + xdg_directories: + dependency: "direct main" + description: + name: xdg_directories + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + url: "https://pub.dev" + source: hosted + version: "1.0.3" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 668033e..9c6ae8c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: font_awesome_flutter: ^9.2.0 url_launcher: ^6.1.14 equatable: ^2.0.3 # 用于对象比较 + sqflite: ^2.0.0+3 + xdg_directories: ^1.0.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.