diff --git a/.gitignore b/.gitignore index 3820a95..d12fe35 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +*.jks +gradle.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c66ee72..df23dcd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,12 +1,12 @@ plugins { id("com.android.application") - id("kotlin-android") + id("org.jetbrains.kotlin.android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { - namespace = "com.example.untitled1" + namespace = "com.crylia.hirameki" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -20,17 +20,31 @@ android { } defaultConfig { - applicationId = "com.crylia.wanikani_kanji_srs" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. + applicationId = "com.crylia.hirameki_srs" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + create("release") { + storeFile = file("hirameki-release-key.jks") + storePassword = project.findProperty("KEYSTORE_PASSWORD")?.toString() + keyAlias = project.findProperty("KEY_ALIAS")?.toString() + keyPassword = project.findProperty("KEY_PASSWORD")?.toString() + } + } + buildTypes { - release { + getByName("release") { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 045e21d..0f0132b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ + android:label="Hirameki SRS" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> ("clean") { diff --git a/lib/main.dart b/lib/main.dart index ea189bf..e7b4dd9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,7 +20,7 @@ class WkApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'WaniKani SRS', + title: 'Hirameki SRS', debugShowCheckedModeBanner: false, theme: ThemeData.dark(useMaterial3: true), home: const StartScreen(), diff --git a/lib/src/app.dart b/lib/src/app.dart deleted file mode 100644 index 2c877ed..0000000 --- a/lib/src/app.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'screens/home_screen.dart'; -import 'services/deck_repository.dart'; - -class WkApp extends StatelessWidget { - const WkApp({super.key}); - - @override - Widget build(BuildContext context) { - return MultiProvider( - providers: [ - Provider(create: (_) => DeckRepository()), - ], - child: MaterialApp( - title: 'WaniKani SRS', - theme: ThemeData(useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)), - home: const HomeScreen(), - ), - ); - } -} diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index d8264a9..200af1c 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -1,13 +1,12 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/kanji_item.dart'; import '../api/wk_client.dart'; -class DeckRepository with ChangeNotifier { +class DeckRepository { Database? _db; String? _apiKey; @@ -28,39 +27,50 @@ class DeckRepository with ChangeNotifier { version: 7, onCreate: (db, version) async { await db.execute( - '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); + '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''', + ); await db.execute( - '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'''); + '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''', + ); await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))'''); + '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', + ); await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)'''); + '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''', + ); await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))'''); + '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', + ); }, onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute( - '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)'''); + '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''', + ); } if (oldVersion < 3) { // Migration from version 2 to 3 was flawed, so we just drop the columns if they exist } if (oldVersion < 4) { await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))'''); + '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', + ); // We are not migrating the old srs data, as it was not mode-specific. // Old columns will be dropped. } if (oldVersion < 5) { await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)'''); + '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''', + ); await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))'''); + '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', + ); } if (oldVersion < 6) { try { - await db.execute('ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT'); + await db.execute( + 'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT', + ); } catch (_) { // Ignore error, column might already exist } @@ -81,17 +91,19 @@ class DeckRepository with ChangeNotifier { Future saveApiKey(String apiKey) async { final db = await _openDb(); - await db.insert( - 'settings', - {'key': 'apiKey', 'value': apiKey}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('settings', { + 'key': 'apiKey', + 'value': apiKey, + }, conflictAlgorithm: ConflictAlgorithm.replace); } Future loadApiKey() async { final db = await _openDb(); - final rows = - await db.query('settings', where: 'key = ?', whereArgs: ['apiKey']); + final rows = await db.query( + 'settings', + where: 'key = ?', + whereArgs: ['apiKey'], + ); if (rows.isNotEmpty) { _apiKey = rows.first['value'] as String; return _apiKey; @@ -103,18 +115,14 @@ class DeckRepository with ChangeNotifier { final db = await _openDb(); final batch = db.batch(); for (final it in items) { - batch.insert( - 'kanji', - { - 'id': it.id, - 'level': it.level, - 'characters': it.characters, - 'meanings': it.meanings.join('|'), - 'onyomi': it.onyomi.join('|'), - 'kunyomi': it.kunyomi.join('|'), - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + batch.insert('kanji', { + 'id': it.id, + 'level': it.level, + 'characters': it.characters, + 'meanings': it.meanings.join('|'), + 'onyomi': it.onyomi.join('|'), + 'kunyomi': it.kunyomi.join('|'), + }, conflictAlgorithm: ConflictAlgorithm.replace); } await batch.commit(noResult: true); } @@ -123,23 +131,25 @@ class DeckRepository with ChangeNotifier { final db = await _openDb(); final rows = await db.query('kanji'); final kanjiItems = rows - .map((r) => KanjiItem( - id: r['id'] as int, - level: r['level'] as int? ?? 0, - characters: r['characters'] as String, - meanings: (r['meanings'] as String) - .split('|') - .where((s) => s.isNotEmpty) - .toList(), - onyomi: (r['onyomi'] as String) - .split('|') - .where((s) => s.isNotEmpty) - .toList(), - kunyomi: (r['kunyomi'] as String) - .split('|') - .where((s) => s.isNotEmpty) - .toList(), - )) + .map( + (r) => KanjiItem( + id: r['id'] as int, + level: r['level'] as int? ?? 0, + characters: r['characters'] as String, + meanings: (r['meanings'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + onyomi: (r['onyomi'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + kunyomi: (r['kunyomi'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + ), + ) .toList(); for (final item in kanjiItems) { @@ -155,11 +165,17 @@ class DeckRepository with ChangeNotifier { Future> getSrsItems(int kanjiId) async { final db = await _openDb(); - final rows = await db.query('srs_items', where: 'kanjiId = ?', whereArgs: [kanjiId]); + final rows = await db.query( + 'srs_items', + where: 'kanjiId = ?', + whereArgs: [kanjiId], + ); return rows.map((r) { return SrsItem( kanjiId: r['kanjiId'] as int, - quizMode: QuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String), + quizMode: QuizMode.values.firstWhere( + (e) => e.toString() == r['quizMode'] as String, + ), readingType: r['readingType'] as String?, srsStage: r['srsStage'] as int, lastAsked: DateTime.parse(r['lastAsked'] as String), @@ -182,17 +198,13 @@ class DeckRepository with ChangeNotifier { Future insertSrsItem(SrsItem item) async { final db = await _openDb(); - await db.insert( - 'srs_items', - { - 'kanjiId': item.kanjiId, - 'quizMode': item.quizMode.toString(), - 'readingType': item.readingType, - 'srsStage': item.srsStage, - 'lastAsked': item.lastAsked.toIso8601String(), - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('srs_items', { + 'kanjiId': item.kanjiId, + 'quizMode': item.quizMode.toString(), + 'readingType': item.readingType, + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, conflictAlgorithm: ConflictAlgorithm.replace); } Future> fetchAndCacheFromWk([String? apiKey]) async { @@ -200,8 +212,9 @@ class DeckRepository with ChangeNotifier { if (key == null) throw Exception('API key not set'); final client = WkClient(key); - final assignments = - await client.fetchAllAssignments(subjectTypes: ['kanji']); + final assignments = await client.fetchAllAssignments( + subjectTypes: ['kanji'], + ); final unlocked = {}; for (final a in assignments) { @@ -220,10 +233,12 @@ class DeckRepository with ChangeNotifier { final subjects = await client.fetchSubjectsByIds(unlocked.toList()); final items = subjects - .where((s) => - s['object'] == 'kanji' || - (s['data'] != null && - (s['data'] as Map)['object_type'] == 'kanji')) + .where( + (s) => + s['object'] == 'kanji' || + (s['data'] != null && + (s['data'] as Map)['object_type'] == 'kanji'), + ) .map((s) => KanjiItem.fromSubject(s)) .where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty) .toList(); @@ -234,11 +249,17 @@ class DeckRepository with ChangeNotifier { Future> getVocabSrsItems(int vocabId) async { final db = await _openDb(); - final rows = await db.query('srs_vocab_items', where: 'vocabId = ?', whereArgs: [vocabId]); + final rows = await db.query( + 'srs_vocab_items', + where: 'vocabId = ?', + whereArgs: [vocabId], + ); return rows.map((r) { return VocabSrsItem( vocabId: r['vocabId'] as int, - quizMode: VocabQuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String), + quizMode: VocabQuizMode.values.firstWhere( + (e) => e.toString() == r['quizMode'] as String, + ), srsStage: r['srsStage'] as int, lastAsked: DateTime.parse(r['lastAsked'] as String), ); @@ -260,16 +281,12 @@ class DeckRepository with ChangeNotifier { Future insertVocabSrsItem(VocabSrsItem item) async { final db = await _openDb(); - await db.insert( - 'srs_vocab_items', - { - 'vocabId': item.vocabId, - 'quizMode': item.quizMode.toString(), - 'srsStage': item.srsStage, - 'lastAsked': item.lastAsked.toIso8601String(), - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('srs_vocab_items', { + 'vocabId': item.vocabId, + 'quizMode': item.quizMode.toString(), + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, conflictAlgorithm: ConflictAlgorithm.replace); } Future saveVocabulary(List items) async { @@ -279,18 +296,14 @@ class DeckRepository with ChangeNotifier { final audios = it.pronunciationAudios .map((a) => {'url': a.url, 'gender': a.gender}) .toList(); - batch.insert( - 'vocabulary', - { - 'id': it.id, - 'level': it.level, - 'characters': it.characters, - 'meanings': it.meanings.join('|'), - 'readings': it.readings.join('|'), - 'pronunciation_audios': jsonEncode(audios), - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + batch.insert('vocabulary', { + 'id': it.id, + 'level': it.level, + 'characters': it.characters, + 'meanings': it.meanings.join('|'), + 'readings': it.readings.join('|'), + 'pronunciation_audios': jsonEncode(audios), + }, conflictAlgorithm: ConflictAlgorithm.replace); } await batch.commit(noResult: true); } @@ -298,39 +311,39 @@ class DeckRepository with ChangeNotifier { Future> loadVocabulary() async { final db = await _openDb(); final rows = await db.query('vocabulary'); - final vocabItems = rows - .map((r) { - final audiosRaw = r['pronunciation_audios'] as String?; - final List audios = []; - if (audiosRaw != null && audiosRaw.isNotEmpty) { - try { - final decoded = jsonDecode(audiosRaw) as List; - for (final audioData in decoded) { - audios.add(PronunciationAudio( - url: audioData['url'] as String, - gender: audioData['gender'] as String, - )); - } - } catch (e) { - // Error decoding, so we'll just have no audio for this item - } + final vocabItems = rows.map((r) { + final audiosRaw = r['pronunciation_audios'] as String?; + final List audios = []; + if (audiosRaw != null && audiosRaw.isNotEmpty) { + try { + final decoded = jsonDecode(audiosRaw) as List; + for (final audioData in decoded) { + audios.add( + PronunciationAudio( + url: audioData['url'] as String, + gender: audioData['gender'] as String, + ), + ); } - return VocabularyItem( - id: r['id'] as int, - level: r['level'] as int? ?? 0, - characters: r['characters'] as String, - meanings: (r['meanings'] as String) - .split('|') - .where((s) => s.isNotEmpty) - .toList(), - readings: (r['readings'] as String) - .split('|') - .where((s) => s.isNotEmpty) - .toList(), - pronunciationAudios: audios, - ); - }) - .toList(); + } catch (e) { + // Error decoding, so we'll just have no audio for this item + } + } + return VocabularyItem( + id: r['id'] as int, + level: r['level'] as int? ?? 0, + characters: r['characters'] as String, + meanings: (r['meanings'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + readings: (r['readings'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + pronunciationAudios: audios, + ); + }).toList(); for (final item in vocabItems) { final srsItems = await getVocabSrsItems(item.id); @@ -343,13 +356,16 @@ class DeckRepository with ChangeNotifier { return vocabItems; } - Future> fetchAndCacheVocabularyFromWk([String? apiKey]) async { + Future> fetchAndCacheVocabularyFromWk([ + String? apiKey, + ]) async { final key = apiKey ?? _apiKey; if (key == null) throw Exception('API key not set'); final client = WkClient(key); - final assignments = - await client.fetchAllAssignments(subjectTypes: ['vocabulary']); + final assignments = await client.fetchAllAssignments( + subjectTypes: ['vocabulary'], + ); final unlocked = {}; for (final a in assignments) { @@ -368,10 +384,12 @@ class DeckRepository with ChangeNotifier { final subjects = await client.fetchSubjectsByIds(unlocked.toList()); final items = subjects - .where((s) => - s['object'] == 'vocabulary' || - (s['data'] != null && - (s['data'] as Map)['object_type'] == 'vocabulary')) + .where( + (s) => + s['object'] == 'vocabulary' || + (s['data'] != null && + (s['data'] as Map)['object_type'] == 'vocabulary'), + ) .map((s) => VocabularyItem.fromSubject(s)) .where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty) .toList(); @@ -379,4 +397,4 @@ class DeckRepository with ChangeNotifier { await saveVocabulary(items); return items; } -} \ No newline at end of file +} diff --git a/pubspec.yaml b/pubspec.yaml index b1e13f4..ad9a205 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,27 +2,27 @@ name: hirameki_srs description: A simple and effective Spaced Repetition System (SRS) app for learning Japanese kanji and vocabulary. version: 0.1.0+1 environment: - sdk: '>=3.3.0 <4.0.0' + sdk: ">=3.9.0 <4.0.0" dependencies: + audioplayers: any flutter: sdk: flutter - shared_preferences: ^2.5.3 - sqflite: ^2.4.2 - path_provider: ^2.1.5 - path: ^1.9.1 - provider: ^6.1.5+1 - http: ^1.5.0 - audioplayers: ^6.0.0 + http: any + path: any + path_provider: any + provider: any + shared_preferences: any + sqflite: any dev_dependencies: flutter_test: sdk: flutter - mockito: ^5.5.0 - test: ^1.26.2 - build_runner: ^2.4.10 - flutter_launcher_icons: ^0.14.4 - flutter_lints: ^6.0.0 + mockito: any + test: any + build_runner: any + flutter_launcher_icons: any + flutter_lints: any flutter_icons: android: true