From b13f5f97727b290d2c377a9032aac91fb3bc8283 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Wed, 11 Mar 2026 21:07:15 +0900 Subject: [PATCH 1/5] feat: save drawing --- backend/src/db/schema.ts | 16 + backend/src/index.ts | 205 +++++- backend/src/schemas/drawing.ts | 39 + doc | 567 +++++++++++++++ lib/api/api_client.dart | 4 + lib/api/clients/drawings_client.dart | 41 ++ lib/api/clients/drawings_client.g.dart | 169 +++++ lib/api/export.dart | 12 + .../api_drawings_batch_request_body.dart | 22 + .../api_drawings_batch_request_body.g.dart | 19 + lib/api/models/api_drawings_request_body.dart | 28 + .../models/api_drawings_request_body.g.dart | 27 + lib/api/models/drawings.dart | 28 + lib/api/models/drawings.g.dart | 23 + lib/api/models/get_api_drawings_response.dart | 34 + .../models/get_api_drawings_response.g.dart | 33 + lib/api/models/points.dart | 22 + lib/api/models/points.g.dart | 15 + lib/api/models/points2.dart | 22 + lib/api/models/points2.g.dart | 15 + lib/api/models/points3.dart | 22 + lib/api/models/points3.g.dart | 15 + lib/api/models/points4.dart | 22 + lib/api/models/points4.g.dart | 15 + lib/api/models/points5.dart | 22 + lib/api/models/points5.g.dart | 15 + .../post_api_drawings_batch_response.dart | 34 + .../post_api_drawings_batch_response.g.dart | 33 + .../models/post_api_drawings_response.dart | 34 + .../models/post_api_drawings_response.g.dart | 33 + lib/features/map/data/drawing_repository.dart | 213 ++++++ .../map/data/drawing_repository_base.dart | 9 + .../map/data/local_drawing_storage.dart | 105 +++ lib/features/map/models/drawing_path.dart | 27 + lib/features/map/presentation/map_screen.dart | 13 +- .../map/presentation/widgets/controls.dart | 27 +- .../presentation/widgets/drawing_canvas.dart | 36 +- .../map/providers/drawing_provider.dart | 296 +++++++- .../map/services/drawing_sync_service.dart | 209 ++++++ test/features/map/data/drawing_data_test.dart | 132 ++++ .../map/data/local_drawing_storage_test.dart | 153 ++++ test/features/map/mocks/mocks.dart | 6 + .../map/models/drawing_path_test.dart | 87 +++ .../services/drawing_sync_service_test.dart | 672 ++++++++++++++++++ 44 files changed, 3510 insertions(+), 61 deletions(-) create mode 100644 backend/src/schemas/drawing.ts create mode 100644 lib/api/clients/drawings_client.dart create mode 100644 lib/api/clients/drawings_client.g.dart create mode 100644 lib/api/models/api_drawings_batch_request_body.dart create mode 100644 lib/api/models/api_drawings_batch_request_body.g.dart create mode 100644 lib/api/models/api_drawings_request_body.dart create mode 100644 lib/api/models/api_drawings_request_body.g.dart create mode 100644 lib/api/models/drawings.dart create mode 100644 lib/api/models/drawings.g.dart create mode 100644 lib/api/models/get_api_drawings_response.dart create mode 100644 lib/api/models/get_api_drawings_response.g.dart create mode 100644 lib/api/models/points.dart create mode 100644 lib/api/models/points.g.dart create mode 100644 lib/api/models/points2.dart create mode 100644 lib/api/models/points2.g.dart create mode 100644 lib/api/models/points3.dart create mode 100644 lib/api/models/points3.g.dart create mode 100644 lib/api/models/points4.dart create mode 100644 lib/api/models/points4.g.dart create mode 100644 lib/api/models/points5.dart create mode 100644 lib/api/models/points5.g.dart create mode 100644 lib/api/models/post_api_drawings_batch_response.dart create mode 100644 lib/api/models/post_api_drawings_batch_response.g.dart create mode 100644 lib/api/models/post_api_drawings_response.dart create mode 100644 lib/api/models/post_api_drawings_response.g.dart create mode 100644 lib/features/map/data/drawing_repository.dart create mode 100644 lib/features/map/data/drawing_repository_base.dart create mode 100644 lib/features/map/data/local_drawing_storage.dart create mode 100644 lib/features/map/services/drawing_sync_service.dart create mode 100644 test/features/map/data/drawing_data_test.dart create mode 100644 test/features/map/data/local_drawing_storage_test.dart create mode 100644 test/features/map/models/drawing_path_test.dart create mode 100644 test/features/map/services/drawing_sync_service_test.dart diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 15fa7b9..ebed9ff 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,6 +1,7 @@ import { boolean, doublePrecision, + jsonb, pgTable, text, timestamp, @@ -80,3 +81,18 @@ export const pins = pgTable("pins", { export type Pin = typeof pins.$inferSelect; export type NewPin = typeof pins.$inferInsert; + +export const drawings = pgTable("drawings", { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + mapId: uuid("map_id"), + points: jsonb("points").notNull(), + color: text("color").notNull(), + strokeWidth: doublePrecision("stroke_width").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export type Drawing = typeof drawings.$inferSelect; +export type NewDrawing = typeof drawings.$inferInsert; diff --git a/backend/src/index.ts b/backend/src/index.ts index 4d1c2ea..a8f767b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,7 +11,13 @@ import { } from "hono-openapi"; import postgres from "postgres"; import { type AuthEnv, createAuth } from "./auth"; -import { pins } from "./db/schema"; +import { drawings, pins } from "./db/schema"; +import { + BatchCreateDrawingsSchema, + CreateDrawingSchema, + DrawingSchema, + DrawingsArraySchema, +} from "./schemas/drawing"; import { BatchCreatePinsSchema, CreatePinSchema, @@ -345,4 +351,201 @@ app.post( }, ); +// Drawings API + +app.get( + "/api/drawings", + describeRoute({ + tags: ["drawings"], + summary: "Get all drawings for current user", + responses: { + 200: { + description: "List of drawings", + content: { + "application/json": { schema: resolver(DrawingsArraySchema) }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + async (c) => { + const userId = c.get("userId"); + + try { + const data = await withDb(c.env.DATABASE_URL, (db) => + db + .select() + .from(drawings) + .where(eq(drawings.userId, userId)) + .orderBy(desc(drawings.createdAt)), + ); + + return c.json(data); + } catch (error) { + console.error("Failed to get drawings:", error); + return c.json({ error: "Failed to get drawings" }, 500); + } + }, +); + +app.post( + "/api/drawings", + describeRoute({ + tags: ["drawings"], + summary: "Create a new drawing", + responses: { + 201: { + description: "Drawing created", + content: { "application/json": { schema: resolver(DrawingSchema) } }, + }, + 400: { + description: "Invalid request", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + validator("json", CreateDrawingSchema), + async (c) => { + const userId = c.get("userId"); + const body = c.req.valid("json"); + + try { + const [data] = await withDb(c.env.DATABASE_URL, (db) => + db + .insert(drawings) + .values({ + userId, + mapId: body.mapId ?? null, + points: body.points, + color: body.color, + strokeWidth: body.strokeWidth, + }) + .returning(), + ); + + return c.json(data, 201); + } catch (error) { + console.error("Failed to add drawing:", error); + return c.json({ error: "Failed to add drawing" }, 500); + } + }, +); + +app.delete( + "/api/drawings/:id", + describeRoute({ + tags: ["drawings"], + summary: "Delete a drawing", + responses: { + 204: { + description: "Drawing deleted", + }, + 400: { + description: "Invalid drawing ID", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + async (c) => { + const userId = c.get("userId"); + const drawingId = c.req.param("id"); + + if (!drawingId || !/^[0-9a-f-]{36}$/i.test(drawingId)) { + return c.json({ error: "Invalid drawing ID" }, 400); + } + + try { + await withDb(c.env.DATABASE_URL, (db) => + db + .delete(drawings) + .where(and(eq(drawings.id, drawingId), eq(drawings.userId, userId))), + ); + + return c.body(null, 204); + } catch (error) { + console.error("Failed to delete drawing:", error); + return c.json({ error: "Failed to delete drawing" }, 500); + } + }, +); + +app.post( + "/api/drawings/batch", + describeRoute({ + tags: ["drawings"], + summary: "Create multiple drawings at once", + responses: { + 201: { + description: "Drawings created", + content: { + "application/json": { schema: resolver(DrawingsArraySchema) }, + }, + }, + 400: { + description: "Invalid request", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + validator("json", BatchCreateDrawingsSchema), + async (c) => { + const userId = c.get("userId"); + const body = c.req.valid("json"); + + const drawingsToInsert = body.drawings.map((drawing) => ({ + userId, + mapId: drawing.mapId ?? null, + points: drawing.points, + color: drawing.color, + strokeWidth: drawing.strokeWidth, + })); + + try { + const data = await withDb(c.env.DATABASE_URL, (db) => + db.insert(drawings).values(drawingsToInsert).returning(), + ); + + return c.json(data, 201); + } catch (error) { + console.error("Failed to batch insert drawings:", error); + return c.json({ error: "Failed to add drawings" }, 500); + } + }, +); + export default app; diff --git a/backend/src/schemas/drawing.ts b/backend/src/schemas/drawing.ts new file mode 100644 index 0000000..6d1ea49 --- /dev/null +++ b/backend/src/schemas/drawing.ts @@ -0,0 +1,39 @@ +import * as v from "valibot"; + +export const PointSchema = v.object({ + lat: v.number(), + lng: v.number(), +}); + +export const CreateDrawingSchema = v.object({ + points: v.array(PointSchema), + color: v.string(), + strokeWidth: v.pipe(v.number(), v.minValue(0.1), v.maxValue(50)), + mapId: v.optional(v.nullable(v.string())), +}); + +export const BatchCreateDrawingsSchema = v.object({ + drawings: v.pipe( + v.array( + v.object({ + points: v.array(PointSchema), + color: v.string(), + strokeWidth: v.pipe(v.number(), v.minValue(0.1), v.maxValue(50)), + mapId: v.optional(v.nullable(v.string())), + }), + ), + v.maxLength(100), + ), +}); + +export const DrawingSchema = v.object({ + id: v.string(), + userId: v.string(), + mapId: v.nullable(v.string()), + points: v.array(PointSchema), + color: v.string(), + strokeWidth: v.number(), + createdAt: v.string(), +}); + +export const DrawingsArraySchema = v.array(DrawingSchema); diff --git a/doc b/doc index aa4df5d..6c3255a 100644 --- a/doc +++ b/doc @@ -518,6 +518,573 @@ } } } + }, + "/api/drawings": { + "get": { + "operationId": "getApiDrawings", + "tags": [ + "drawings" + ], + "summary": "Get all drawings for current user", + "responses": { + "200": { + "description": "List of drawings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "color": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "mapId", + "points", + "color", + "strokeWidth", + "createdAt" + ] + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "post": { + "operationId": "postApiDrawings", + "tags": [ + "drawings" + ], + "summary": "Create a new drawing", + "responses": { + "201": { + "description": "Drawing created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "color": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "mapId", + "points", + "color", + "strokeWidth", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "color": { + "type": "string" + }, + "strokeWidth": { + "type": "number", + "minimum": 0.1, + "maximum": 50 + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "points", + "color", + "strokeWidth" + ] + } + } + } + } + } + }, + "/api/drawings/{id}": { + "delete": { + "operationId": "deleteApiDrawingsById", + "tags": [ + "drawings" + ], + "summary": "Delete a drawing", + "responses": { + "204": { + "description": "Drawing deleted" + }, + "400": { + "description": "Invalid drawing ID", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ] + } + }, + "/api/drawings/batch": { + "post": { + "operationId": "postApiDrawingsBatch", + "tags": [ + "drawings" + ], + "summary": "Create multiple drawings at once", + "responses": { + "201": { + "description": "Drawings created", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "color": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "mapId", + "points", + "color", + "strokeWidth", + "createdAt" + ] + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "drawings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + } + }, + "required": [ + "lat", + "lng" + ] + } + }, + "color": { + "type": "string" + }, + "strokeWidth": { + "type": "number", + "minimum": 0.1, + "maximum": 50 + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "points", + "color", + "strokeWidth" + ] + }, + "maxItems": 100 + } + }, + "required": [ + "drawings" + ] + } + } + } + } + } } }, "components": {} diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 77f1c98..83f56e4 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -7,6 +7,7 @@ import 'package:dio/dio.dart'; import 'clients/system_client.dart'; import 'clients/user_client.dart'; import 'clients/pins_client.dart'; +import 'clients/drawings_client.dart'; /// Memomap API `v1.0.0`. /// @@ -26,10 +27,13 @@ class ApiClient { SystemClient? _system; UserClient? _user; PinsClient? _pins; + DrawingsClient? _drawings; SystemClient get system => _system ??= SystemClient(_dio, baseUrl: _baseUrl); UserClient get user => _user ??= UserClient(_dio, baseUrl: _baseUrl); PinsClient get pins => _pins ??= PinsClient(_dio, baseUrl: _baseUrl); + + DrawingsClient get drawings => _drawings ??= DrawingsClient(_dio, baseUrl: _baseUrl); } diff --git a/lib/api/clients/drawings_client.dart b/lib/api/clients/drawings_client.dart new file mode 100644 index 0000000..89a6535 --- /dev/null +++ b/lib/api/clients/drawings_client.dart @@ -0,0 +1,41 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../models/api_drawings_batch_request_body.dart'; +import '../models/api_drawings_request_body.dart'; +import '../models/get_api_drawings_response.dart'; +import '../models/post_api_drawings_batch_response.dart'; +import '../models/post_api_drawings_response.dart'; + +part 'drawings_client.g.dart'; + +@RestApi() +abstract class DrawingsClient { + factory DrawingsClient(Dio dio, {String? baseUrl}) = _DrawingsClient; + + /// Get all drawings for current user + @GET('/api/drawings') + Future> getApiDrawings(); + + /// Create a new drawing + @POST('/api/drawings') + Future postApiDrawings({ + @Body() ApiDrawingsRequestBody? body, + }); + + /// Delete a drawing + @DELETE('/api/drawings/{id}') + Future deleteApiDrawingsById({ + @Path('id') required String id, + }); + + /// Create multiple drawings at once + @POST('/api/drawings/batch') + Future> postApiDrawingsBatch({ + @Body() ApiDrawingsBatchRequestBody? body, + }); +} diff --git a/lib/api/clients/drawings_client.g.dart b/lib/api/clients/drawings_client.g.dart new file mode 100644 index 0000000..951cb6f --- /dev/null +++ b/lib/api/clients/drawings_client.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drawings_client.dart'; + +// dart format off + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter,avoid_unused_constructor_parameters,unreachable_from_main + +class _DrawingsClient implements DrawingsClient { + _DrawingsClient(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getApiDrawings() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/drawings', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => + GetApiDrawingsResponse.fromJson(i as Map), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + @override + Future postApiDrawings({ + ApiDrawingsRequestBody? body, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = {}; + _data.addAll(body?.toJson() ?? {}); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/drawings', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late PostApiDrawingsResponse _value; + try { + _value = PostApiDrawingsResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + @override + Future deleteApiDrawingsById({required String id}) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'DELETE', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/drawings/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + await _dio.fetch(_options); + } + + @override + Future> postApiDrawingsBatch({ + ApiDrawingsBatchRequestBody? body, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = {}; + _data.addAll(body?.toJson() ?? {}); + final _options = _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/drawings/batch', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => PostApiDrawingsBatchResponse.fromJson( + i as Map, + ), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} + +// dart format on diff --git a/lib/api/export.dart b/lib/api/export.dart index 5361286..e0e3caa 100644 --- a/lib/api/export.dart +++ b/lib/api/export.dart @@ -6,6 +6,7 @@ export 'clients/system_client.dart'; export 'clients/user_client.dart'; export 'clients/pins_client.dart'; +export 'clients/drawings_client.dart'; // Data classes export 'models/get_health_response.dart'; export 'models/get_api_me_response.dart'; @@ -15,6 +16,17 @@ export 'models/api_pins_request_body.dart'; export 'models/post_api_pins_batch_response.dart'; export 'models/pins.dart'; export 'models/api_pins_batch_request_body.dart'; +export 'models/points.dart'; +export 'models/get_api_drawings_response.dart'; +export 'models/points2.dart'; +export 'models/post_api_drawings_response.dart'; +export 'models/points3.dart'; +export 'models/api_drawings_request_body.dart'; +export 'models/points4.dart'; +export 'models/post_api_drawings_batch_response.dart'; +export 'models/points5.dart'; +export 'models/drawings.dart'; +export 'models/api_drawings_batch_request_body.dart'; // Root client export 'api_client.dart'; diff --git a/lib/api/models/api_drawings_batch_request_body.dart b/lib/api/models/api_drawings_batch_request_body.dart new file mode 100644 index 0000000..c699a92 --- /dev/null +++ b/lib/api/models/api_drawings_batch_request_body.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'drawings.dart'; + +part 'api_drawings_batch_request_body.g.dart'; + +@JsonSerializable() +class ApiDrawingsBatchRequestBody { + const ApiDrawingsBatchRequestBody({ + required this.drawings, + }); + + factory ApiDrawingsBatchRequestBody.fromJson(Map json) => _$ApiDrawingsBatchRequestBodyFromJson(json); + + final List drawings; + + Map toJson() => _$ApiDrawingsBatchRequestBodyToJson(this); +} diff --git a/lib/api/models/api_drawings_batch_request_body.g.dart b/lib/api/models/api_drawings_batch_request_body.g.dart new file mode 100644 index 0000000..300f8c5 --- /dev/null +++ b/lib/api/models/api_drawings_batch_request_body.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_drawings_batch_request_body.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiDrawingsBatchRequestBody _$ApiDrawingsBatchRequestBodyFromJson( + Map json, +) => ApiDrawingsBatchRequestBody( + drawings: (json['drawings'] as List) + .map((e) => Drawings.fromJson(e as Map)) + .toList(), +); + +Map _$ApiDrawingsBatchRequestBodyToJson( + ApiDrawingsBatchRequestBody instance, +) => {'drawings': instance.drawings}; diff --git a/lib/api/models/api_drawings_request_body.dart b/lib/api/models/api_drawings_request_body.dart new file mode 100644 index 0000000..29dfc78 --- /dev/null +++ b/lib/api/models/api_drawings_request_body.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'points3.dart'; + +part 'api_drawings_request_body.g.dart'; + +@JsonSerializable() +class ApiDrawingsRequestBody { + const ApiDrawingsRequestBody({ + required this.points, + required this.color, + required this.strokeWidth, + this.mapId, + }); + + factory ApiDrawingsRequestBody.fromJson(Map json) => _$ApiDrawingsRequestBodyFromJson(json); + + final List points; + final String color; + final num strokeWidth; + final String? mapId; + + Map toJson() => _$ApiDrawingsRequestBodyToJson(this); +} diff --git a/lib/api/models/api_drawings_request_body.g.dart b/lib/api/models/api_drawings_request_body.g.dart new file mode 100644 index 0000000..f8e09ee --- /dev/null +++ b/lib/api/models/api_drawings_request_body.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_drawings_request_body.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiDrawingsRequestBody _$ApiDrawingsRequestBodyFromJson( + Map json, +) => ApiDrawingsRequestBody( + points: (json['points'] as List) + .map((e) => Points3.fromJson(e as Map)) + .toList(), + color: json['color'] as String, + strokeWidth: json['strokeWidth'] as num, + mapId: json['mapId'] as String?, +); + +Map _$ApiDrawingsRequestBodyToJson( + ApiDrawingsRequestBody instance, +) => { + 'points': instance.points, + 'color': instance.color, + 'strokeWidth': instance.strokeWidth, + 'mapId': instance.mapId, +}; diff --git a/lib/api/models/drawings.dart b/lib/api/models/drawings.dart new file mode 100644 index 0000000..1136643 --- /dev/null +++ b/lib/api/models/drawings.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'points5.dart'; + +part 'drawings.g.dart'; + +@JsonSerializable() +class Drawings { + const Drawings({ + required this.points, + required this.color, + required this.strokeWidth, + this.mapId, + }); + + factory Drawings.fromJson(Map json) => _$DrawingsFromJson(json); + + final List points; + final String color; + final num strokeWidth; + final String? mapId; + + Map toJson() => _$DrawingsToJson(this); +} diff --git a/lib/api/models/drawings.g.dart b/lib/api/models/drawings.g.dart new file mode 100644 index 0000000..38385d5 --- /dev/null +++ b/lib/api/models/drawings.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'drawings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Drawings _$DrawingsFromJson(Map json) => Drawings( + points: (json['points'] as List) + .map((e) => Points5.fromJson(e as Map)) + .toList(), + color: json['color'] as String, + strokeWidth: json['strokeWidth'] as num, + mapId: json['mapId'] as String?, +); + +Map _$DrawingsToJson(Drawings instance) => { + 'points': instance.points, + 'color': instance.color, + 'strokeWidth': instance.strokeWidth, + 'mapId': instance.mapId, +}; diff --git a/lib/api/models/get_api_drawings_response.dart b/lib/api/models/get_api_drawings_response.dart new file mode 100644 index 0000000..dad2c83 --- /dev/null +++ b/lib/api/models/get_api_drawings_response.dart @@ -0,0 +1,34 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'points.dart'; + +part 'get_api_drawings_response.g.dart'; + +@JsonSerializable() +class GetApiDrawingsResponse { + const GetApiDrawingsResponse({ + required this.id, + required this.userId, + required this.mapId, + required this.points, + required this.color, + required this.strokeWidth, + required this.createdAt, + }); + + factory GetApiDrawingsResponse.fromJson(Map json) => _$GetApiDrawingsResponseFromJson(json); + + final String id; + final String userId; + final String? mapId; + final List points; + final String color; + final num strokeWidth; + final String createdAt; + + Map toJson() => _$GetApiDrawingsResponseToJson(this); +} diff --git a/lib/api/models/get_api_drawings_response.g.dart b/lib/api/models/get_api_drawings_response.g.dart new file mode 100644 index 0000000..2f0e03f --- /dev/null +++ b/lib/api/models/get_api_drawings_response.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_api_drawings_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetApiDrawingsResponse _$GetApiDrawingsResponseFromJson( + Map json, +) => GetApiDrawingsResponse( + id: json['id'] as String, + userId: json['userId'] as String, + mapId: json['mapId'] as String?, + points: (json['points'] as List) + .map((e) => Points.fromJson(e as Map)) + .toList(), + color: json['color'] as String, + strokeWidth: json['strokeWidth'] as num, + createdAt: json['createdAt'] as String, +); + +Map _$GetApiDrawingsResponseToJson( + GetApiDrawingsResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'mapId': instance.mapId, + 'points': instance.points, + 'color': instance.color, + 'strokeWidth': instance.strokeWidth, + 'createdAt': instance.createdAt, +}; diff --git a/lib/api/models/points.dart b/lib/api/models/points.dart new file mode 100644 index 0000000..d11e559 --- /dev/null +++ b/lib/api/models/points.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'points.g.dart'; + +@JsonSerializable() +class Points { + const Points({ + required this.lat, + required this.lng, + }); + + factory Points.fromJson(Map json) => _$PointsFromJson(json); + + final num lat; + final num lng; + + Map toJson() => _$PointsToJson(this); +} diff --git a/lib/api/models/points.g.dart b/lib/api/models/points.g.dart new file mode 100644 index 0000000..05335f1 --- /dev/null +++ b/lib/api/models/points.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Points _$PointsFromJson(Map json) => + Points(lat: json['lat'] as num, lng: json['lng'] as num); + +Map _$PointsToJson(Points instance) => { + 'lat': instance.lat, + 'lng': instance.lng, +}; diff --git a/lib/api/models/points2.dart b/lib/api/models/points2.dart new file mode 100644 index 0000000..d052c17 --- /dev/null +++ b/lib/api/models/points2.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'points2.g.dart'; + +@JsonSerializable() +class Points2 { + const Points2({ + required this.lat, + required this.lng, + }); + + factory Points2.fromJson(Map json) => _$Points2FromJson(json); + + final num lat; + final num lng; + + Map toJson() => _$Points2ToJson(this); +} diff --git a/lib/api/models/points2.g.dart b/lib/api/models/points2.g.dart new file mode 100644 index 0000000..338a925 --- /dev/null +++ b/lib/api/models/points2.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points2.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Points2 _$Points2FromJson(Map json) => + Points2(lat: json['lat'] as num, lng: json['lng'] as num); + +Map _$Points2ToJson(Points2 instance) => { + 'lat': instance.lat, + 'lng': instance.lng, +}; diff --git a/lib/api/models/points3.dart b/lib/api/models/points3.dart new file mode 100644 index 0000000..5f4b9e7 --- /dev/null +++ b/lib/api/models/points3.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'points3.g.dart'; + +@JsonSerializable() +class Points3 { + const Points3({ + required this.lat, + required this.lng, + }); + + factory Points3.fromJson(Map json) => _$Points3FromJson(json); + + final num lat; + final num lng; + + Map toJson() => _$Points3ToJson(this); +} diff --git a/lib/api/models/points3.g.dart b/lib/api/models/points3.g.dart new file mode 100644 index 0000000..ae051e2 --- /dev/null +++ b/lib/api/models/points3.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points3.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Points3 _$Points3FromJson(Map json) => + Points3(lat: json['lat'] as num, lng: json['lng'] as num); + +Map _$Points3ToJson(Points3 instance) => { + 'lat': instance.lat, + 'lng': instance.lng, +}; diff --git a/lib/api/models/points4.dart b/lib/api/models/points4.dart new file mode 100644 index 0000000..d7bd1c2 --- /dev/null +++ b/lib/api/models/points4.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'points4.g.dart'; + +@JsonSerializable() +class Points4 { + const Points4({ + required this.lat, + required this.lng, + }); + + factory Points4.fromJson(Map json) => _$Points4FromJson(json); + + final num lat; + final num lng; + + Map toJson() => _$Points4ToJson(this); +} diff --git a/lib/api/models/points4.g.dart b/lib/api/models/points4.g.dart new file mode 100644 index 0000000..52fef8b --- /dev/null +++ b/lib/api/models/points4.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points4.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Points4 _$Points4FromJson(Map json) => + Points4(lat: json['lat'] as num, lng: json['lng'] as num); + +Map _$Points4ToJson(Points4 instance) => { + 'lat': instance.lat, + 'lng': instance.lng, +}; diff --git a/lib/api/models/points5.dart b/lib/api/models/points5.dart new file mode 100644 index 0000000..84bf919 --- /dev/null +++ b/lib/api/models/points5.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'points5.g.dart'; + +@JsonSerializable() +class Points5 { + const Points5({ + required this.lat, + required this.lng, + }); + + factory Points5.fromJson(Map json) => _$Points5FromJson(json); + + final num lat; + final num lng; + + Map toJson() => _$Points5ToJson(this); +} diff --git a/lib/api/models/points5.g.dart b/lib/api/models/points5.g.dart new file mode 100644 index 0000000..9d2297e --- /dev/null +++ b/lib/api/models/points5.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'points5.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Points5 _$Points5FromJson(Map json) => + Points5(lat: json['lat'] as num, lng: json['lng'] as num); + +Map _$Points5ToJson(Points5 instance) => { + 'lat': instance.lat, + 'lng': instance.lng, +}; diff --git a/lib/api/models/post_api_drawings_batch_response.dart b/lib/api/models/post_api_drawings_batch_response.dart new file mode 100644 index 0000000..a95ee9d --- /dev/null +++ b/lib/api/models/post_api_drawings_batch_response.dart @@ -0,0 +1,34 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'points4.dart'; + +part 'post_api_drawings_batch_response.g.dart'; + +@JsonSerializable() +class PostApiDrawingsBatchResponse { + const PostApiDrawingsBatchResponse({ + required this.id, + required this.userId, + required this.mapId, + required this.points, + required this.color, + required this.strokeWidth, + required this.createdAt, + }); + + factory PostApiDrawingsBatchResponse.fromJson(Map json) => _$PostApiDrawingsBatchResponseFromJson(json); + + final String id; + final String userId; + final String? mapId; + final List points; + final String color; + final num strokeWidth; + final String createdAt; + + Map toJson() => _$PostApiDrawingsBatchResponseToJson(this); +} diff --git a/lib/api/models/post_api_drawings_batch_response.g.dart b/lib/api/models/post_api_drawings_batch_response.g.dart new file mode 100644 index 0000000..88e0b1c --- /dev/null +++ b/lib/api/models/post_api_drawings_batch_response.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_api_drawings_batch_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PostApiDrawingsBatchResponse _$PostApiDrawingsBatchResponseFromJson( + Map json, +) => PostApiDrawingsBatchResponse( + id: json['id'] as String, + userId: json['userId'] as String, + mapId: json['mapId'] as String?, + points: (json['points'] as List) + .map((e) => Points4.fromJson(e as Map)) + .toList(), + color: json['color'] as String, + strokeWidth: json['strokeWidth'] as num, + createdAt: json['createdAt'] as String, +); + +Map _$PostApiDrawingsBatchResponseToJson( + PostApiDrawingsBatchResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'mapId': instance.mapId, + 'points': instance.points, + 'color': instance.color, + 'strokeWidth': instance.strokeWidth, + 'createdAt': instance.createdAt, +}; diff --git a/lib/api/models/post_api_drawings_response.dart b/lib/api/models/post_api_drawings_response.dart new file mode 100644 index 0000000..2b1d944 --- /dev/null +++ b/lib/api/models/post_api_drawings_response.dart @@ -0,0 +1,34 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +import 'points2.dart'; + +part 'post_api_drawings_response.g.dart'; + +@JsonSerializable() +class PostApiDrawingsResponse { + const PostApiDrawingsResponse({ + required this.id, + required this.userId, + required this.mapId, + required this.points, + required this.color, + required this.strokeWidth, + required this.createdAt, + }); + + factory PostApiDrawingsResponse.fromJson(Map json) => _$PostApiDrawingsResponseFromJson(json); + + final String id; + final String userId; + final String? mapId; + final List points; + final String color; + final num strokeWidth; + final String createdAt; + + Map toJson() => _$PostApiDrawingsResponseToJson(this); +} diff --git a/lib/api/models/post_api_drawings_response.g.dart b/lib/api/models/post_api_drawings_response.g.dart new file mode 100644 index 0000000..ddc648c --- /dev/null +++ b/lib/api/models/post_api_drawings_response.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_api_drawings_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PostApiDrawingsResponse _$PostApiDrawingsResponseFromJson( + Map json, +) => PostApiDrawingsResponse( + id: json['id'] as String, + userId: json['userId'] as String, + mapId: json['mapId'] as String?, + points: (json['points'] as List) + .map((e) => Points2.fromJson(e as Map)) + .toList(), + color: json['color'] as String, + strokeWidth: json['strokeWidth'] as num, + createdAt: json['createdAt'] as String, +); + +Map _$PostApiDrawingsResponseToJson( + PostApiDrawingsResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'mapId': instance.mapId, + 'points': instance.points, + 'color': instance.color, + 'strokeWidth': instance.strokeWidth, + 'createdAt': instance.createdAt, +}; diff --git a/lib/features/map/data/drawing_repository.dart b/lib/features/map/data/drawing_repository.dart new file mode 100644 index 0000000..11ef0e3 --- /dev/null +++ b/lib/features/map/data/drawing_repository.dart @@ -0,0 +1,213 @@ +import 'dart:ui'; + +import 'package:latlong2/latlong.dart'; +import 'package:memomap/api/api_client.dart'; +import 'package:memomap/api/models/api_drawings_batch_request_body.dart'; +import 'package:memomap/api/models/api_drawings_request_body.dart'; +import 'package:memomap/api/models/drawings.dart'; +import 'package:memomap/api/models/get_api_drawings_response.dart'; +import 'package:memomap/api/models/points3.dart'; +import 'package:memomap/api/models/points5.dart'; +import 'package:memomap/api/models/post_api_drawings_batch_response.dart'; +import 'package:memomap/api/models/post_api_drawings_response.dart'; +import 'package:memomap/config/backend_config.dart'; +import 'package:memomap/features/auth/data/token_storage.dart'; +import 'package:memomap/features/map/data/drawing_repository_base.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:uuid/uuid.dart'; + +DrawingPath _pointsToDrawingPath({ + required List points, + required String colorStr, + required num strokeWidth, +}) { + final colorValue = int.parse(colorStr); + return DrawingPath( + points: points, + color: Color(colorValue), + strokeWidth: strokeWidth.toDouble(), + ); +} + +DrawingData _createDrawingData({ + required String id, + required String userId, + required String? mapId, + required DrawingPath path, + required String createdAt, +}) => + DrawingData( + id: id, + userId: userId, + mapId: mapId, + path: path, + createdAt: DateTime.parse(createdAt), + ); + +extension GetApiDrawingsResponseExt on GetApiDrawingsResponse { + DrawingData toDrawingData() => _createDrawingData( + id: id, + userId: userId, + mapId: mapId, + path: _pointsToDrawingPath( + points: points.map((p) => LatLng(p.lat.toDouble(), p.lng.toDouble())).toList(), + colorStr: color, + strokeWidth: strokeWidth, + ), + createdAt: createdAt, + ); +} + +extension PostApiDrawingsResponseExt on PostApiDrawingsResponse { + DrawingData toDrawingData() => _createDrawingData( + id: id, + userId: userId, + mapId: mapId, + path: _pointsToDrawingPath( + points: points.map((p) => LatLng(p.lat.toDouble(), p.lng.toDouble())).toList(), + colorStr: color, + strokeWidth: strokeWidth, + ), + createdAt: createdAt, + ); +} + +extension PostApiDrawingsBatchResponseExt on PostApiDrawingsBatchResponse { + DrawingData toDrawingData() => _createDrawingData( + id: id, + userId: userId, + mapId: mapId, + path: _pointsToDrawingPath( + points: points.map((p) => LatLng(p.lat.toDouble(), p.lng.toDouble())).toList(), + colorStr: color, + strokeWidth: strokeWidth, + ), + createdAt: createdAt, + ); +} + +class DrawingData { + final String id; + final String? userId; + final String? mapId; + final DrawingPath path; + final DateTime createdAt; + final bool isLocal; + + DrawingData({ + required this.id, + required this.userId, + required this.mapId, + required this.path, + required this.createdAt, + this.isLocal = false, + }); + + factory DrawingData.local(DrawingPath path) { + return DrawingData( + id: const Uuid().v4(), + userId: null, + mapId: null, + path: path, + createdAt: DateTime.now(), + isLocal: true, + ); + } + + factory DrawingData.fromJson(Map json) { + return DrawingData( + id: json['id'] as String, + userId: json['userId'] as String?, + mapId: json['mapId'] as String?, + path: DrawingPath.fromJson(json['path'] as Map), + createdAt: DateTime.parse(json['createdAt'] as String), + isLocal: json['isLocal'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'mapId': mapId, + 'path': path.toJson(), + 'createdAt': createdAt.toUtc().toIso8601String(), + 'isLocal': isLocal, + }; + } +} + +class DrawingRepository implements DrawingRepositoryBase { + DrawingRepository._internal(this._api); + + final ApiClient _api; + + static DrawingRepository? _instance; + + static Future getInstance() async { + if (_instance != null) return _instance!; + final api = await BackendConfig.createApiClient(); + _instance = DrawingRepository._internal(api); + return _instance!; + } + + Future _isAuthenticated() async { + final token = await TokenStorage.getSessionId(); + return token != null; + } + + @override + Future> getDrawings() async { + if (!await _isAuthenticated()) return []; + + final response = await _api.drawings.getApiDrawings(); + return response.map((r) => r.toDrawingData()).toList(); + } + + @override + Future addDrawing(DrawingPath path) async { + if (!await _isAuthenticated()) return null; + + final response = await _api.drawings.postApiDrawings( + body: ApiDrawingsRequestBody( + points: path.points + .map((p) => Points3(lat: p.latitude, lng: p.longitude)) + .toList(), + color: path.color.toARGB32().toString(), + strokeWidth: path.strokeWidth, + ), + ); + + return response.toDrawingData(); + } + + @override + Future deleteDrawing(String id) async { + if (!await _isAuthenticated()) return; + + await _api.drawings.deleteApiDrawingsById(id: id); + } + + @override + Future> uploadLocalDrawings( + List localDrawings, + ) async { + if (!await _isAuthenticated() || localDrawings.isEmpty) return []; + + final response = await _api.drawings.postApiDrawingsBatch( + body: ApiDrawingsBatchRequestBody( + drawings: localDrawings + .map((drawing) => Drawings( + points: drawing.path.points + .map((p) => Points5(lat: p.latitude, lng: p.longitude)) + .toList(), + color: drawing.path.color.toARGB32().toString(), + strokeWidth: drawing.path.strokeWidth, + )) + .toList(), + ), + ); + + return response.map((r) => r.toDrawingData()).toList(); + } +} diff --git a/lib/features/map/data/drawing_repository_base.dart b/lib/features/map/data/drawing_repository_base.dart new file mode 100644 index 0000000..229af79 --- /dev/null +++ b/lib/features/map/data/drawing_repository_base.dart @@ -0,0 +1,9 @@ +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; + +abstract interface class DrawingRepositoryBase { + Future> getDrawings(); + Future addDrawing(DrawingPath path); + Future deleteDrawing(String id); + Future> uploadLocalDrawings(List localDrawings); +} diff --git a/lib/features/map/data/local_drawing_storage.dart b/lib/features/map/data/local_drawing_storage.dart new file mode 100644 index 0000000..95a5b85 --- /dev/null +++ b/lib/features/map/data/local_drawing_storage.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class LocalDrawingStorageBase { + Future> getCachedDrawings(); + Future setCachedDrawings(List drawings); + + Future> getLocalDrawings(); + Future setLocalDrawings(List drawings); + + Future> getPendingDeletions(); + Future setPendingDeletions(List ids); + + Future getLastUserId(); + Future setLastUserId(String? userId); + + Future clearAll(); +} + +class SharedPreferencesLocalDrawingStorage implements LocalDrawingStorageBase { + static const _cachedDrawingsKey = 'memomap_cached_drawings'; + static const _localDrawingsKey = 'memomap_local_drawings'; + static const _pendingDeletionsKey = 'memomap_drawing_pending_deletions'; + static const _lastUserIdKey = 'memomap_drawing_last_user_id'; + + final SharedPreferencesAsync _prefs; + + SharedPreferencesLocalDrawingStorage(this._prefs); + + @override + Future> getCachedDrawings() async { + final jsonString = await _prefs.getString(_cachedDrawingsKey); + if (jsonString == null) return []; + return _decodeDrawingList(jsonString); + } + + @override + Future setCachedDrawings(List drawings) async { + final jsonString = _encodeDrawingList(drawings); + await _prefs.setString(_cachedDrawingsKey, jsonString); + } + + @override + Future> getLocalDrawings() async { + final jsonString = await _prefs.getString(_localDrawingsKey); + if (jsonString == null) return []; + return _decodeDrawingList(jsonString); + } + + @override + Future setLocalDrawings(List drawings) async { + final jsonString = _encodeDrawingList(drawings); + await _prefs.setString(_localDrawingsKey, jsonString); + } + + @override + Future> getPendingDeletions() async { + final jsonString = await _prefs.getString(_pendingDeletionsKey); + if (jsonString == null) return []; + final list = jsonDecode(jsonString) as List; + return list.cast(); + } + + @override + Future setPendingDeletions(List ids) async { + final jsonString = jsonEncode(ids); + await _prefs.setString(_pendingDeletionsKey, jsonString); + } + + @override + Future getLastUserId() async { + return _prefs.getString(_lastUserIdKey); + } + + @override + Future setLastUserId(String? userId) async { + if (userId == null) { + await _prefs.remove(_lastUserIdKey); + } else { + await _prefs.setString(_lastUserIdKey, userId); + } + } + + @override + Future clearAll() async { + await Future.wait([ + _prefs.remove(_cachedDrawingsKey), + _prefs.remove(_localDrawingsKey), + _prefs.remove(_pendingDeletionsKey), + ]); + } + + String _encodeDrawingList(List drawings) { + return jsonEncode(drawings.map((d) => d.toJson()).toList()); + } + + List _decodeDrawingList(String jsonString) { + final list = jsonDecode(jsonString) as List; + return list + .map((e) => DrawingData.fromJson(e as Map)) + .toList(); + } +} diff --git a/lib/features/map/models/drawing_path.dart b/lib/features/map/models/drawing_path.dart index 2ecc6dd..d99a391 100644 --- a/lib/features/map/models/drawing_path.dart +++ b/lib/features/map/models/drawing_path.dart @@ -12,6 +12,33 @@ class DrawingPath { required this.strokeWidth, }); + factory DrawingPath.fromJson(Map json) { + final pointsList = json['points'] as List; + return DrawingPath( + points: pointsList + .map((p) => LatLng( + (p['lat'] as num).toDouble(), + (p['lng'] as num).toDouble(), + )) + .toList(), + color: Color(json['color'] as int), + strokeWidth: (json['strokeWidth'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'points': points + .map((p) => { + 'lat': p.latitude, + 'lng': p.longitude, + }) + .toList(), + 'color': color.toARGB32(), + 'strokeWidth': strokeWidth, + }; + } + DrawingPath copyWith({ List? points, Color? color, diff --git a/lib/features/map/presentation/map_screen.dart b/lib/features/map/presentation/map_screen.dart index f606fbd..73dcd84 100644 --- a/lib/features/map/presentation/map_screen.dart +++ b/lib/features/map/presentation/map_screen.dart @@ -49,7 +49,10 @@ class _MapScreenState extends ConsumerState { final isAuthenticated = ref.watch(isAuthenticatedProvider); final user = ref.watch(currentUserProvider); final pinsAsync = ref.watch(pinsProvider); - final drawingState = ref.watch(drawingProvider); + final drawingStateAsync = ref.watch(drawingProvider); + final drawingState = drawingStateAsync.valueOrNull; + final isDrawingMode = drawingState?.isDrawingMode ?? false; + final paths = drawingState?.paths ?? []; return Scaffold( appBar: AppBar( @@ -85,13 +88,13 @@ class _MapScreenState extends ConsumerState { initialCenter: const LatLng(35.6895, 139.6917), initialZoom: 9.2, interactionOptions: InteractionOptions( - flags: drawingState.isDrawingMode + flags: isDrawingMode ? InteractiveFlag.none : InteractiveFlag.all & ~InteractiveFlag.doubleTapZoom, ), onTap: (tapPosition, latlng) { - if (!drawingState.isDrawingMode) { + if (!isDrawingMode) { ref.read(pinsProvider.notifier).addPin(latlng); } }, @@ -103,7 +106,7 @@ class _MapScreenState extends ConsumerState { userAgentPackageName: 'dev.fleaflet.flutter_map.example', ), PolylineLayer( - polylines: drawingState.paths + polylines: paths .map( (path) => Polyline( points: path.points, @@ -134,7 +137,7 @@ class _MapScreenState extends ConsumerState { ], ), IgnorePointer( - ignoring: !drawingState.isDrawingMode, + ignoring: !isDrawingMode, child: DrawingCanvas(mapController: _mapController), ), Positioned( diff --git a/lib/features/map/presentation/widgets/controls.dart b/lib/features/map/presentation/widgets/controls.dart index 0b7d0fa..39ac8a4 100644 --- a/lib/features/map/presentation/widgets/controls.dart +++ b/lib/features/map/presentation/widgets/controls.dart @@ -8,9 +8,15 @@ class Controls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final drawingState = ref.watch(drawingProvider); + final drawingStateAsync = ref.watch(drawingProvider); + final drawingState = drawingStateAsync.valueOrNull; final drawingNotifier = ref.read(drawingProvider.notifier); + final isDrawingMode = drawingState?.isDrawingMode ?? false; + final isEraserMode = drawingState?.isEraserMode ?? false; + final selectedColor = drawingState?.selectedColor ?? Colors.red; + final strokeWidth = drawingState?.strokeWidth ?? 3.0; + return Row( children: [ // ピンモードボタン @@ -24,7 +30,7 @@ class Controls extends ConsumerWidget { Icon( Icons.pin_drop, size: 60, - color: !drawingState.isDrawingMode ? Colors.red : Colors.grey, + color: !isDrawingMode ? Colors.red : Colors.grey, ), ], ), @@ -45,7 +51,7 @@ class Controls extends ConsumerWidget { ), ); }, - child: drawingState.isDrawingMode + child: isDrawingMode ? Container( key: const ValueKey('expanded_controls'), padding: const EdgeInsets.only(bottom: 24, top: 12), @@ -65,13 +71,13 @@ class Controls extends ConsumerWidget { IconButton( icon: Icon( MyFlutterApp.eraser_1, - color: drawingState.isEraserMode + color: isEraserMode ? Colors.blue : Colors.black, ), tooltip: '消しゴム', onPressed: () => drawingNotifier.setEraserMode( - !drawingState.isEraserMode, + !isEraserMode, ), ), ], @@ -97,9 +103,8 @@ class Controls extends ConsumerWidget { (entry) => _ColorCircle( index: entry.key, isSelected: - !drawingState.isEraserMode && - drawingState.selectedColor == - entry.value, + !isEraserMode && + selectedColor == entry.value, color: entry.value, onTap: () => drawingNotifier .selectColor(entry.value), @@ -109,10 +114,10 @@ class Controls extends ConsumerWidget { ), const SizedBox(height: 10), _StrokeWidthSlider( - color: drawingState.isEraserMode + color: isEraserMode ? Colors.grey - : drawingState.selectedColor, - width: drawingState.strokeWidth, + : selectedColor, + width: strokeWidth, setWidth: (newWidth) => drawingNotifier.changeStrokeWidth(newWidth), ), diff --git a/lib/features/map/presentation/widgets/drawing_canvas.dart b/lib/features/map/presentation/widgets/drawing_canvas.dart index 6b10b20..660c110 100644 --- a/lib/features/map/presentation/widgets/drawing_canvas.dart +++ b/lib/features/map/presentation/widgets/drawing_canvas.dart @@ -19,7 +19,8 @@ class _DrawingCanvasState extends ConsumerState { Offset? _eraserPosition; void _handleEraser(Offset localPosition) { - final drawingState = ref.read(drawingProvider); + final drawingState = ref.read(drawingProvider).valueOrNull; + if (drawingState == null) return; final drawingNotifier = ref.read(drawingProvider.notifier); final latLng = widget.mapController.camera.screenOffsetToLatLng( @@ -27,7 +28,6 @@ class _DrawingCanvasState extends ConsumerState { ); final distance = const Distance(); - // 消しゴムの半径(メートル換算)。strokeWidthを基準にする final metersPerPixel = 156543.03392 * math.cos(latLng.latitude * math.pi / 180) / @@ -60,7 +60,6 @@ class _DrawingCanvasState extends ConsumerState { } } - // 最後のセグメントを追加 if (currentSegment.length > 1) { newPaths.add( DrawingPath( @@ -70,29 +69,35 @@ class _DrawingCanvasState extends ConsumerState { ), ); } else if (pathModified && currentSegment.length <= 1) { - // セグメントが短くなりすぎた場合は追加しない + // Segment too short after modification, skip } else if (!pathModified) { - // 修正がなかった場合は元のパスを維持 newPaths.add(path); } } if (changed) { - drawingNotifier.setPaths(newPaths); + drawingNotifier.updateEraserPaths(newPaths); } } @override Widget build(BuildContext context) { - final drawingState = ref.watch(drawingProvider); + final drawingStateAsync = ref.watch(drawingProvider); + final drawingState = drawingStateAsync.valueOrNull; final drawingNotifier = ref.read(drawingProvider.notifier); + final isDrawingMode = drawingState?.isDrawingMode ?? false; + final isEraserMode = drawingState?.isEraserMode ?? false; + final selectedColor = drawingState?.selectedColor ?? Colors.red; + final strokeWidth = drawingState?.strokeWidth ?? 3.0; + return GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (details) { - if (!drawingState.isDrawingMode) return; + if (!isDrawingMode) return; - if (drawingState.isEraserMode) { + if (isEraserMode) { + drawingNotifier.startEraserOperation(); setState(() { _eraserPosition = details.localPosition; }); @@ -106,15 +111,15 @@ class _DrawingCanvasState extends ConsumerState { setState(() { _currentPath = DrawingPath( points: [latLng], - color: drawingState.selectedColor, - strokeWidth: drawingState.strokeWidth, + color: selectedColor, + strokeWidth: strokeWidth, ); }); }, onPanUpdate: (details) { - if (!drawingState.isDrawingMode) return; + if (!isDrawingMode) return; - if (drawingState.isEraserMode) { + if (isEraserMode) { setState(() { _eraserPosition = details.localPosition; }); @@ -133,7 +138,8 @@ class _DrawingCanvasState extends ConsumerState { }); }, onPanEnd: (details) { - if (drawingState.isEraserMode) { + if (isEraserMode) { + drawingNotifier.finishEraserOperation(); setState(() { _eraserPosition = null; }); @@ -159,7 +165,7 @@ class _DrawingCanvasState extends ConsumerState { size: Size.infinite, painter: _EraserPainter( _eraserPosition!, - drawingState.strokeWidth * 2, + strokeWidth * 2, ), ), ], diff --git a/lib/features/map/providers/drawing_provider.dart b/lib/features/map/providers/drawing_provider.dart index cce3d92..29d9487 100644 --- a/lib/features/map/providers/drawing_provider.dart +++ b/lib/features/map/providers/drawing_provider.dart @@ -1,93 +1,331 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/providers/pin_provider.dart'; +import 'package:memomap/features/map/services/drawing_sync_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +export 'package:memomap/features/map/data/drawing_repository.dart' + show DrawingData; + +final localDrawingStorageProvider = Provider((ref) { + final prefs = SharedPreferencesAsync(); + return SharedPreferencesLocalDrawingStorage(prefs); +}); + +final drawingRepositoryProvider = + FutureProvider((ref) async { + return DrawingRepository.getInstance(); +}); + +final drawingSyncServiceProvider = + FutureProvider((ref) async { + final storage = ref.watch(localDrawingStorageProvider); + final networkChecker = ref.watch(networkCheckerProvider); + final repository = await ref.watch(drawingRepositoryProvider.future); + + return DrawingSyncService( + storage: storage, + networkChecker: networkChecker, + repository: repository, + ); +}); class DrawingState { - final List paths; + final List drawingDataList; final Color selectedColor; final double strokeWidth; final bool isDrawingMode; final bool isEraserMode; + /// Temporary paths during eraser operation (null when not erasing) + final List? eraserTempPaths; + + /// Original drawings saved at eraser operation start (for persistence) + final List? eraserOriginalDrawings; + DrawingState({ - required this.paths, + required this.drawingDataList, required this.selectedColor, required this.strokeWidth, required this.isDrawingMode, this.isEraserMode = false, + this.eraserTempPaths, + this.eraserOriginalDrawings, }); + /// Returns eraser temp paths if in eraser operation, otherwise actual paths + List get paths => + eraserTempPaths ?? drawingDataList.map((d) => d.path).toList(); + DrawingState copyWith({ - List? paths, + List? drawingDataList, Color? selectedColor, double? strokeWidth, bool? isDrawingMode, bool? isEraserMode, + List? Function()? eraserTempPaths, + List? Function()? eraserOriginalDrawings, }) { return DrawingState( - paths: paths ?? this.paths, + drawingDataList: drawingDataList ?? this.drawingDataList, selectedColor: selectedColor ?? this.selectedColor, strokeWidth: strokeWidth ?? this.strokeWidth, isDrawingMode: isDrawingMode ?? this.isDrawingMode, isEraserMode: isEraserMode ?? this.isEraserMode, + eraserTempPaths: + eraserTempPaths != null ? eraserTempPaths() : this.eraserTempPaths, + eraserOriginalDrawings: eraserOriginalDrawings != null + ? eraserOriginalDrawings() + : this.eraserOriginalDrawings, ); } } -class DrawingNotifier extends Notifier { +final drawingProvider = + AsyncNotifierProvider(() { + return DrawingNotifier(); +}); + +class DrawingNotifier extends AsyncNotifier { @override - DrawingState build() { - return DrawingState( - paths: [], + Future build() async { + ref.listen(sessionProvider, (prev, next) { + final prevUserId = prev?.valueOrNull?.user.id; + final nextUserId = next.valueOrNull?.user.id; + if (prevUserId != nextUserId) { + ref.invalidateSelf(); + } + }); + + final syncService = await ref.watch(drawingSyncServiceProvider.future); + final session = ref.read(sessionProvider).valueOrNull; + final currentUserId = session?.user.id; + + await syncService.clearIfUserChanged(currentUserId); + + final cachedDrawings = await syncService.getAllDrawings(); + final initialState = DrawingState( + drawingDataList: cachedDrawings, selectedColor: Colors.red, strokeWidth: 3, isDrawingMode: false, ); + + state = AsyncValue.data(initialState); + + if (currentUserId != null) { + _syncInBackground(syncService); + } + + return initialState; + } + + Future _syncInBackground(DrawingSyncService syncService) async { + try { + await syncService.syncWithServer(); + final freshDrawings = await syncService.getAllDrawings(); + final current = state.valueOrNull; + // Skip update if eraser operation is in progress + if (current != null && current.eraserTempPaths == null) { + state = AsyncValue.data( + current.copyWith(drawingDataList: freshDrawings), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Background sync failed: $e\n$st'); + } + } } void toggleDrawingMode() { - state = state.copyWith(isDrawingMode: !state.isDrawingMode); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data( + current.copyWith(isDrawingMode: !current.isDrawingMode), + ); + } } void setDrawingMode(bool value) { - state = state.copyWith(isDrawingMode: value); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data(current.copyWith(isDrawingMode: value)); + } } void setEraserMode(bool value) { - state = state.copyWith(isEraserMode: value); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data(current.copyWith(isEraserMode: value)); + } + } + + Future addPath(DrawingPath path) async { + final current = state.valueOrNull; + if (current == null) return; + + final isAuthenticated = ref.read(isAuthenticatedProvider); + + final optimisticDrawing = DrawingData.local(path); + state = AsyncValue.data( + current.copyWith( + drawingDataList: [...current.drawingDataList, optimisticDrawing], + ), + ); + + try { + final syncService = await ref.read(drawingSyncServiceProvider.future); + final realDrawing = await syncService.addDrawing( + path: path, + isAuthenticated: isAuthenticated, + ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith( + drawingDataList: updated.drawingDataList + .map((d) => d.id == optimisticDrawing.id ? realDrawing : d) + .toList(), + ), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to add drawing: $e\n$st'); + } + } } - void addPath(DrawingPath path) { - state = state.copyWith(paths: [...state.paths, path]); + // ============ Eraser Operation Methods ============ + + /// Call when eraser operation starts (on pan start) + void startEraserOperation() { + final current = state.valueOrNull; + if (current == null) return; + + state = AsyncValue.data( + current.copyWith( + eraserOriginalDrawings: () => current.drawingDataList, + eraserTempPaths: () => current.paths, + ), + ); } - void setPaths(List paths) { - state = state.copyWith(paths: paths); + /// Call during eraser operation to update display (on pan update) + void updateEraserPaths(List paths) { + final current = state.valueOrNull; + if (current == null) return; + + state = AsyncValue.data( + current.copyWith(eraserTempPaths: () => paths), + ); } - void removePathAt(int index) { - final newPaths = List.from(state.paths); - newPaths.removeAt(index); - state = state.copyWith(paths: newPaths); + /// Call when eraser operation ends (on pan end) - persists changes + Future finishEraserOperation() async { + final current = state.valueOrNull; + if (current == null) return; + + final originalDrawings = current.eraserOriginalDrawings; + final tempPaths = current.eraserTempPaths; + + // If no eraser operation was active, nothing to do + if (originalDrawings == null || tempPaths == null) return; + + // Update drawingDataList with temp paths AND clear eraser state atomically + // This prevents UI from showing old drawings during persistence + final tempDrawingDataList = + tempPaths.map((p) => DrawingData.local(p)).toList(); + state = AsyncValue.data( + current.copyWith( + drawingDataList: tempDrawingDataList, + eraserOriginalDrawings: () => null, + eraserTempPaths: () => null, + ), + ); + + // Persist changes in background + try { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(drawingSyncServiceProvider.future); + + final newDrawingDataList = await syncService.replaceDrawings( + oldDrawings: originalDrawings, + newPaths: tempPaths, + isAuthenticated: isAuthenticated, + ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith(drawingDataList: newDrawingDataList), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to persist eraser changes: $e\n$st'); + } + // On error, tempDrawingDataList is already set, so no action needed + } } - void undo() { - if (state.paths.isNotEmpty) { - state = state.copyWith( - paths: state.paths.sublist(0, state.paths.length - 1), + Future removePathAt(int index) async { + final current = state.valueOrNull; + if (current == null) return; + if (index < 0 || index >= current.drawingDataList.length) return; + + final drawing = current.drawingDataList[index]; + final isAuthenticated = ref.read(isAuthenticatedProvider); + + final newList = List.from(current.drawingDataList); + newList.removeAt(index); + state = AsyncValue.data(current.copyWith(drawingDataList: newList)); + + try { + final syncService = await ref.read(drawingSyncServiceProvider.future); + await syncService.deleteDrawing( + drawing: drawing, + isAuthenticated: isAuthenticated, ); + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to delete drawing: $e\n$st'); + } + } + } + + Future undo() async { + final current = state.valueOrNull; + if (current != null && current.drawingDataList.isNotEmpty) { + await removePathAt(current.drawingDataList.length - 1); } } void selectColor(Color color) { - state = state.copyWith(selectedColor: color, isEraserMode: false); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data( + current.copyWith(selectedColor: color, isEraserMode: false), + ); + } } void changeStrokeWidth(double width) { - state = state.copyWith(strokeWidth: width); + final current = state.valueOrNull; + if (current != null) { + state = AsyncValue.data(current.copyWith(strokeWidth: width)); + } } -} -final drawingProvider = NotifierProvider(() { - return DrawingNotifier(); -}); + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => build()); + } +} diff --git a/lib/features/map/services/drawing_sync_service.dart b/lib/features/map/services/drawing_sync_service.dart new file mode 100644 index 0000000..5846a48 --- /dev/null +++ b/lib/features/map/services/drawing_sync_service.dart @@ -0,0 +1,209 @@ +import 'package:flutter/foundation.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/data/drawing_repository_base.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; +import 'package:memomap/features/map/data/network_checker.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; + +class DrawingSyncService { + final LocalDrawingStorageBase storage; + final NetworkCheckerBase networkChecker; + final DrawingRepositoryBase repository; + + DrawingSyncService({ + required this.storage, + required this.networkChecker, + required this.repository, + }); + + Future> getAllDrawings() async { + final cachedDrawings = await storage.getCachedDrawings(); + final localDrawings = await storage.getLocalDrawings(); + return [...cachedDrawings, ...localDrawings]; + } + + Future addDrawing({ + required DrawingPath path, + required bool isAuthenticated, + }) async { + if (!isAuthenticated) { + return _addLocalDrawing(path); + } + + final isOnline = await networkChecker.isOnline; + if (!isOnline) { + return _addLocalDrawing(path); + } + + try { + final serverDrawing = await repository.addDrawing(path); + if (serverDrawing != null) { + final cachedDrawings = await storage.getCachedDrawings(); + await storage.setCachedDrawings([serverDrawing, ...cachedDrawings]); + return serverDrawing; + } + return _addLocalDrawing(path); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to add drawing to server: $e'); + } + return _addLocalDrawing(path); + } + } + + Future _addLocalDrawing(DrawingPath path) async { + final localDrawing = DrawingData.local(path); + final localDrawings = await storage.getLocalDrawings(); + await storage.setLocalDrawings([localDrawing, ...localDrawings]); + return localDrawing; + } + + Future deleteDrawing({ + required DrawingData drawing, + required bool isAuthenticated, + }) async { + if (drawing.isLocal) { + final localDrawings = await storage.getLocalDrawings(); + await storage.setLocalDrawings( + localDrawings.where((d) => d.id != drawing.id).toList(), + ); + return; + } + + final isOnline = await networkChecker.isOnline && isAuthenticated; + if (isOnline) { + try { + await repository.deleteDrawing(drawing.id); + await _removeFromCache(drawing.id); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to delete drawing from server: $e'); + } + await _addToPendingDeletions(drawing.id); + await _removeFromCache(drawing.id); + } + } else { + await _addToPendingDeletions(drawing.id); + await _removeFromCache(drawing.id); + } + } + + Future _removeFromCache(String drawingId) async { + final cachedDrawings = await storage.getCachedDrawings(); + await storage.setCachedDrawings( + cachedDrawings.where((d) => d.id != drawingId).toList(), + ); + } + + Future _addToPendingDeletions(String drawingId) async { + final pendingDeletions = await storage.getPendingDeletions(); + await storage.setPendingDeletions([...pendingDeletions, drawingId]); + } + + /// Replaces old drawings with new paths, handling deletions and additions. + /// Uses object identity (identical) to determine if a path was preserved. + Future> replaceDrawings({ + required List oldDrawings, + required List newPaths, + required bool isAuthenticated, + }) async { + final result = []; + final processedOldDrawings = {}; + + for (final newPath in newPaths) { + DrawingData? existingData; + for (final old in oldDrawings) { + if (identical(old.path, newPath)) { + existingData = old; + break; + } + } + + if (existingData != null) { + result.add(existingData); + processedOldDrawings.add(existingData); + } else { + final newDrawing = await addDrawing( + path: newPath, + isAuthenticated: isAuthenticated, + ); + result.add(newDrawing); + } + } + + for (final oldDrawing in oldDrawings) { + if (!processedOldDrawings.contains(oldDrawing)) { + await deleteDrawing( + drawing: oldDrawing, + isAuthenticated: isAuthenticated, + ); + } + } + + return result; + } + + Future syncWithServer() async { + final isOnline = await networkChecker.isOnline; + if (!isOnline) return; + + await _processPendingDeletions(); + await _uploadLocalDrawings(); + await _refreshCacheFromServer(); + } + + Future _processPendingDeletions() async { + final pendingDeletions = await storage.getPendingDeletions(); + if (pendingDeletions.isEmpty) return; + + final failedDeletions = []; + + for (final drawingId in pendingDeletions) { + try { + await repository.deleteDrawing(drawingId); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to delete drawing $drawingId: $e'); + } + failedDeletions.add(drawingId); + } + } + + await storage.setPendingDeletions(failedDeletions); + } + + Future _uploadLocalDrawings() async { + final localDrawings = await storage.getLocalDrawings(); + if (localDrawings.isEmpty) return; + + try { + await repository.uploadLocalDrawings(localDrawings); + await storage.setLocalDrawings([]); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to upload local drawings: $e'); + } + } + } + + Future _refreshCacheFromServer() async { + try { + final serverDrawings = await repository.getDrawings(); + await storage.setCachedDrawings(serverDrawings); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to refresh cache from server: $e'); + } + } + } + + Future clearIfUserChanged(String? currentUserId) async { + final lastUserId = await storage.getLastUserId(); + + if (lastUserId != null && lastUserId != currentUserId) { + await storage.clearAll(); + } + + await storage.setLastUserId(currentUserId); + } +} diff --git a/test/features/map/data/drawing_data_test.dart b/test/features/map/data/drawing_data_test.dart new file mode 100644 index 0000000..0acc11d --- /dev/null +++ b/test/features/map/data/drawing_data_test.dart @@ -0,0 +1,132 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; + +void main() { + group('DrawingData', () { + final testPath = DrawingPath( + points: [ + LatLng(35.681236, 139.767125), + LatLng(35.689521, 139.691704), + ], + color: const Color(0xFFFF0000), + strokeWidth: 5.0, + ); + + test('local() generates UUID and sets isLocal=true', () { + final drawing = DrawingData.local(testPath); + + expect(drawing.id, isNotEmpty); + expect(drawing.id.length, 36); // UUID format + expect(drawing.userId, isNull); + expect(drawing.mapId, isNull); + expect(drawing.path.points.length, 2); + expect(drawing.isLocal, isTrue); + expect(drawing.createdAt, isNotNull); + }); + + test('local() generates unique IDs for each call', () { + final drawing1 = DrawingData.local(testPath); + final drawing2 = DrawingData.local(testPath); + + expect(drawing1.id, isNot(equals(drawing2.id))); + }); + + test('toJson converts all fields correctly', () { + final now = DateTime.utc(2024, 1, 15, 10, 30, 0); + final drawing = DrawingData( + id: 'test-uuid-123', + userId: 'user-456', + mapId: 'map-789', + path: testPath, + createdAt: now, + isLocal: false, + ); + + final json = drawing.toJson(); + + expect(json['id'], 'test-uuid-123'); + expect(json['userId'], 'user-456'); + expect(json['mapId'], 'map-789'); + expect(json['path'], isA>()); + expect(json['createdAt'], '2024-01-15T10:30:00.000Z'); + expect(json['isLocal'], false); + }); + + test('fromJson restores DrawingData from JSON', () { + final json = { + 'id': 'test-uuid-123', + 'userId': 'user-456', + 'mapId': 'map-789', + 'path': { + 'points': [ + {'lat': 35.681236, 'lng': 139.767125}, + {'lat': 35.689521, 'lng': 139.691704}, + ], + 'color': 0xFFFF0000, + 'strokeWidth': 5.0, + }, + 'createdAt': '2024-01-15T10:30:00.000Z', + 'isLocal': false, + }; + + final drawing = DrawingData.fromJson(json); + + expect(drawing.id, 'test-uuid-123'); + expect(drawing.userId, 'user-456'); + expect(drawing.mapId, 'map-789'); + expect(drawing.path.points.length, 2); + expect(drawing.path.color, const Color(0xFFFF0000)); + expect(drawing.createdAt, DateTime.utc(2024, 1, 15, 10, 30, 0)); + expect(drawing.isLocal, false); + }); + + test('fromJson handles null userId and mapId', () { + final json = { + 'id': 'test-uuid-123', + 'userId': null, + 'mapId': null, + 'path': { + 'points': >[], + 'color': 0xFF000000, + 'strokeWidth': 1.0, + }, + 'createdAt': '2024-01-15T10:30:00.000Z', + 'isLocal': true, + }; + + final drawing = DrawingData.fromJson(json); + + expect(drawing.userId, isNull); + expect(drawing.mapId, isNull); + expect(drawing.isLocal, isTrue); + }); + + test('roundtrip: toJson -> fromJson preserves all data', () { + final now = DateTime.utc(2024, 1, 15, 10, 30, 0); + final original = DrawingData( + id: 'test-uuid-123', + userId: 'user-456', + mapId: 'map-789', + path: testPath, + createdAt: now, + isLocal: false, + ); + + final json = original.toJson(); + final restored = DrawingData.fromJson(json); + + expect(restored.id, original.id); + expect(restored.userId, original.userId); + expect(restored.mapId, original.mapId); + expect(restored.path.points.length, original.path.points.length); + expect(restored.path.color, original.path.color); + expect(restored.path.strokeWidth, original.path.strokeWidth); + expect(restored.createdAt, original.createdAt); + expect(restored.isLocal, original.isLocal); + }); + }); +} diff --git a/test/features/map/data/local_drawing_storage_test.dart b/test/features/map/data/local_drawing_storage_test.dart new file mode 100644 index 0000000..9663695 --- /dev/null +++ b/test/features/map/data/local_drawing_storage_test.dart @@ -0,0 +1,153 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockLocalDrawingStorage extends Mock implements LocalDrawingStorageBase {} + +void main() { + group('LocalDrawingStorageBase mock usage', () { + late MockLocalDrawingStorage mockStorage; + + setUp(() { + mockStorage = MockLocalDrawingStorage(); + }); + + DrawingData createTestDrawing({ + String id = 'test-id', + bool isLocal = false, + }) { + return DrawingData( + id: id, + userId: isLocal ? null : 'user-123', + mapId: null, + path: DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(36.0, 140.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: isLocal, + ); + } + + group('cachedDrawings', () { + test('should return empty list when no cached drawings', () async { + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => []); + + final drawings = await mockStorage.getCachedDrawings(); + expect(drawings, isEmpty); + }); + + test('should store and retrieve cached drawings', () async { + final drawings = [ + createTestDrawing(id: 'drawing-1'), + createTestDrawing(id: 'drawing-2'), + ]; + + when(() => mockStorage.setCachedDrawings(drawings)) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => drawings); + + await mockStorage.setCachedDrawings(drawings); + final retrieved = await mockStorage.getCachedDrawings(); + + expect(retrieved.length, 2); + expect(retrieved[0].id, 'drawing-1'); + expect(retrieved[1].id, 'drawing-2'); + verify(() => mockStorage.setCachedDrawings(drawings)).called(1); + }); + }); + + group('localDrawings', () { + test('should return empty list when no local drawings', () async { + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + + final drawings = await mockStorage.getLocalDrawings(); + expect(drawings, isEmpty); + }); + + test('should store and retrieve local drawings', () async { + final drawings = [ + createTestDrawing(id: 'local-1', isLocal: true), + createTestDrawing(id: 'local-2', isLocal: true), + ]; + + when(() => mockStorage.setLocalDrawings(drawings)) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => drawings); + + await mockStorage.setLocalDrawings(drawings); + final retrieved = await mockStorage.getLocalDrawings(); + + expect(retrieved.length, 2); + expect(retrieved[0].id, 'local-1'); + expect(retrieved[0].isLocal, true); + }); + }); + + group('pendingDeletions', () { + test('should return empty list when no pending deletions', () async { + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + + final ids = await mockStorage.getPendingDeletions(); + expect(ids, isEmpty); + }); + + test('should store and retrieve pending deletions', () async { + final ids = ['id-1', 'id-2', 'id-3']; + + when(() => mockStorage.setPendingDeletions(ids)) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => ids); + + await mockStorage.setPendingDeletions(ids); + final retrieved = await mockStorage.getPendingDeletions(); + + expect(retrieved, ['id-1', 'id-2', 'id-3']); + }); + }); + + group('lastUserId', () { + test('should return null when no last user id', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => null); + + final result = await mockStorage.getLastUserId(); + expect(result, isNull); + }); + + test('should store and retrieve last user id', () async { + when(() => mockStorage.setLastUserId('user-123')) + .thenAnswer((_) async {}); + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => 'user-123'); + + await mockStorage.setLastUserId('user-123'); + final result = await mockStorage.getLastUserId(); + expect(result, 'user-123'); + }); + }); + + group('clearAll', () { + test('should clear all stored data', () async { + when(() => mockStorage.clearAll()) + .thenAnswer((_) async {}); + + await mockStorage.clearAll(); + + verify(() => mockStorage.clearAll()).called(1); + }); + }); + }); +} diff --git a/test/features/map/mocks/mocks.dart b/test/features/map/mocks/mocks.dart index 937034e..f8b9080 100644 --- a/test/features/map/mocks/mocks.dart +++ b/test/features/map/mocks/mocks.dart @@ -1,3 +1,5 @@ +import 'package:memomap/features/map/data/drawing_repository_base.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; import 'package:memomap/features/map/data/local_pin_storage.dart'; import 'package:memomap/features/map/data/network_checker.dart'; import 'package:memomap/features/map/data/pin_repository_base.dart'; @@ -8,3 +10,7 @@ class MockLocalPinStorage extends Mock implements LocalPinStorageBase {} class MockNetworkChecker extends Mock implements NetworkCheckerBase {} class MockPinRepository extends Mock implements PinRepositoryBase {} + +class MockLocalDrawingStorage extends Mock implements LocalDrawingStorageBase {} + +class MockDrawingRepository extends Mock implements DrawingRepositoryBase {} diff --git a/test/features/map/models/drawing_path_test.dart b/test/features/map/models/drawing_path_test.dart new file mode 100644 index 0000000..86a81ad --- /dev/null +++ b/test/features/map/models/drawing_path_test.dart @@ -0,0 +1,87 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; + +void main() { + group('DrawingPath serialization', () { + test('toJson converts points, color, and strokeWidth correctly', () { + final path = DrawingPath( + points: [ + LatLng(35.681236, 139.767125), + LatLng(35.689521, 139.691704), + ], + color: const Color(0xFFFF0000), + strokeWidth: 5.0, + ); + + final json = path.toJson(); + + expect(json['points'], [ + {'lat': 35.681236, 'lng': 139.767125}, + {'lat': 35.689521, 'lng': 139.691704}, + ]); + expect(json['color'], 0xFFFF0000); + expect(json['strokeWidth'], 5.0); + }); + + test('fromJson restores DrawingPath from JSON', () { + final json = { + 'points': [ + {'lat': 35.681236, 'lng': 139.767125}, + {'lat': 35.689521, 'lng': 139.691704}, + ], + 'color': 0xFFFF0000, + 'strokeWidth': 5.0, + }; + + final path = DrawingPath.fromJson(json); + + expect(path.points.length, 2); + expect(path.points[0].latitude, 35.681236); + expect(path.points[0].longitude, 139.767125); + expect(path.points[1].latitude, 35.689521); + expect(path.points[1].longitude, 139.691704); + expect(path.color, const Color(0xFFFF0000)); + expect(path.strokeWidth, 5.0); + }); + + test('roundtrip: toJson -> fromJson preserves data', () { + final original = DrawingPath( + points: [ + LatLng(35.681236, 139.767125), + LatLng(35.689521, 139.691704), + LatLng(35.6762, 139.6503), + ], + color: const Color(0x80FF5500), + strokeWidth: 3.5, + ); + + final json = original.toJson(); + final restored = DrawingPath.fromJson(json); + + expect(restored.points.length, original.points.length); + for (var i = 0; i < original.points.length; i++) { + expect(restored.points[i].latitude, original.points[i].latitude); + expect(restored.points[i].longitude, original.points[i].longitude); + } + expect(restored.color, original.color); + expect(restored.strokeWidth, original.strokeWidth); + }); + + test('fromJson handles empty points list', () { + final json = { + 'points': >[], + 'color': 0xFF000000, + 'strokeWidth': 1.0, + }; + + final path = DrawingPath.fromJson(json); + + expect(path.points, isEmpty); + expect(path.color, const Color(0xFF000000)); + expect(path.strokeWidth, 1.0); + }); + }); +} diff --git a/test/features/map/services/drawing_sync_service_test.dart b/test/features/map/services/drawing_sync_service_test.dart new file mode 100644 index 0000000..5f83476 --- /dev/null +++ b/test/features/map/services/drawing_sync_service_test.dart @@ -0,0 +1,672 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/data/drawing_repository_base.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; +import 'package:memomap/features/map/data/network_checker.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/services/drawing_sync_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockLocalDrawingStorage extends Mock implements LocalDrawingStorageBase {} + +class MockNetworkChecker extends Mock implements NetworkCheckerBase {} + +class MockDrawingRepository extends Mock implements DrawingRepositoryBase {} + +void main() { + late DrawingSyncService service; + late MockLocalDrawingStorage mockStorage; + late MockNetworkChecker mockNetworkChecker; + late MockDrawingRepository mockRepository; + + final testPath = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(36.0, 140.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + + DrawingData createTestDrawing({ + String id = 'test-id', + bool isLocal = false, + String? userId, + }) { + return DrawingData( + id: id, + userId: userId ?? (isLocal ? null : 'user-123'), + mapId: null, + path: testPath, + createdAt: DateTime.utc(2024, 1, 15), + isLocal: isLocal, + ); + } + + setUp(() { + mockStorage = MockLocalDrawingStorage(); + mockNetworkChecker = MockNetworkChecker(); + mockRepository = MockDrawingRepository(); + + service = DrawingSyncService( + storage: mockStorage, + networkChecker: mockNetworkChecker, + repository: mockRepository, + ); + }); + + setUpAll(() { + registerFallbackValue(testPath); + registerFallbackValue([]); + }); + + group('getAllDrawings', () { + test('returns combined cached and local drawings', () async { + final cachedDrawings = [createTestDrawing(id: 'cached-1')]; + final localDrawings = [createTestDrawing(id: 'local-1', isLocal: true)]; + + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => cachedDrawings); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => localDrawings); + + final result = await service.getAllDrawings(); + + expect(result.length, 2); + expect(result[0].id, 'cached-1'); + expect(result[1].id, 'local-1'); + }); + + test('returns empty list when no drawings', () async { + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + + final result = await service.getAllDrawings(); + + expect(result, isEmpty); + }); + }); + + group('addDrawing', () { + test('adds to server when online and authenticated', () async { + final serverDrawing = createTestDrawing(id: 'server-id'); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.addDrawing(any())) + .thenAnswer((_) async => serverDrawing); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.addDrawing( + path: testPath, + isAuthenticated: true, + ); + + expect(result.id, 'server-id'); + expect(result.isLocal, false); + verify(() => mockRepository.addDrawing(any())).called(1); + verify(() => mockStorage.setCachedDrawings(any())).called(1); + }); + + test('adds locally when not authenticated', () async { + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.addDrawing( + path: testPath, + isAuthenticated: false, + ); + + expect(result.isLocal, true); + verifyNever(() => mockRepository.addDrawing(any())); + verify(() => mockStorage.setLocalDrawings(any())).called(1); + }); + + test('adds locally when offline', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.addDrawing( + path: testPath, + isAuthenticated: true, + ); + + expect(result.isLocal, true); + verifyNever(() => mockRepository.addDrawing(any())); + }); + + test('falls back to local when server returns null', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.addDrawing(any())) + .thenAnswer((_) async => null); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.addDrawing( + path: testPath, + isAuthenticated: true, + ); + + expect(result.isLocal, true); + }); + + test('falls back to local on server error', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.addDrawing(any())) + .thenThrow(Exception('Server error')); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.addDrawing( + path: testPath, + isAuthenticated: true, + ); + + expect(result.isLocal, true); + }); + }); + + group('deleteDrawing', () { + test('removes from local storage for local drawing', () async { + final localDrawing = createTestDrawing(id: 'local-1', isLocal: true); + final existingLocalDrawings = [localDrawing]; + + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => existingLocalDrawings); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + await service.deleteDrawing( + drawing: localDrawing, + isAuthenticated: true, + ); + + verify(() => mockStorage.setLocalDrawings([])).called(1); + verifyNever(() => mockRepository.deleteDrawing(any())); + }); + + test('deletes from server when online and authenticated', () async { + final serverDrawing = createTestDrawing(id: 'server-1'); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('server-1')) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [serverDrawing]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.deleteDrawing( + drawing: serverDrawing, + isAuthenticated: true, + ); + + verify(() => mockRepository.deleteDrawing('server-1')).called(1); + verify(() => mockStorage.setCachedDrawings([])).called(1); + }); + + test('adds to pending deletions when offline', () async { + final serverDrawing = createTestDrawing(id: 'server-1'); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [serverDrawing]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.deleteDrawing( + drawing: serverDrawing, + isAuthenticated: true, + ); + + verify(() => mockStorage.setPendingDeletions(['server-1'])).called(1); + verify(() => mockStorage.setCachedDrawings([])).called(1); + verifyNever(() => mockRepository.deleteDrawing(any())); + }); + + test('adds to pending deletions on server error', () async { + final serverDrawing = createTestDrawing(id: 'server-1'); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('server-1')) + .thenThrow(Exception('Server error')); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [serverDrawing]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.deleteDrawing( + drawing: serverDrawing, + isAuthenticated: true, + ); + + verify(() => mockStorage.setPendingDeletions(['server-1'])).called(1); + }); + }); + + group('syncWithServer', () { + test('skips sync when offline', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + + await service.syncWithServer(); + + verifyNever(() => mockRepository.getDrawings()); + verifyNever(() => mockRepository.deleteDrawing(any())); + verifyNever(() => mockRepository.uploadLocalDrawings(any())); + }); + + test('processes pending deletions', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => ['pending-1', 'pending-2']); + when(() => mockRepository.deleteDrawing(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockRepository.getDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.syncWithServer(); + + verify(() => mockRepository.deleteDrawing('pending-1')).called(1); + verify(() => mockRepository.deleteDrawing('pending-2')).called(1); + verify(() => mockStorage.setPendingDeletions([])).called(1); + }); + + test('uploads local drawings', () async { + final localDrawing = createTestDrawing(id: 'local-1', isLocal: true); + final uploadedDrawing = createTestDrawing(id: 'server-new'); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => [localDrawing]); + when(() => mockRepository.uploadLocalDrawings(any())) + .thenAnswer((_) async => [uploadedDrawing]); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + when(() => mockRepository.getDrawings()) + .thenAnswer((_) async => [uploadedDrawing]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.syncWithServer(); + + verify(() => mockRepository.uploadLocalDrawings(any())).called(1); + verify(() => mockStorage.setLocalDrawings([])).called(1); + }); + + test('refreshes cache from server', () async { + final serverDrawings = [ + createTestDrawing(id: 'server-1'), + createTestDrawing(id: 'server-2'), + ]; + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockRepository.getDrawings()) + .thenAnswer((_) async => serverDrawings); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + await service.syncWithServer(); + + verify(() => mockStorage.setCachedDrawings(serverDrawings)).called(1); + }); + }); + + group('replaceDrawings', () { + test('preserves unchanged paths and deletes removed paths', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final path2 = DrawingPath( + points: [LatLng(36.0, 140.0)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + final drawing1 = DrawingData( + id: 'drawing-1', + userId: 'user-123', + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final drawing2 = DrawingData( + id: 'drawing-2', + userId: 'user-123', + mapId: null, + path: path2, + createdAt: DateTime.utc(2024, 1, 15), + ); + final oldDrawings = [drawing1, drawing2]; + + // path1 is preserved, path2 is removed + final newPaths = [path1]; + + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('drawing-2')) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [drawing1, drawing2]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: oldDrawings, + newPaths: newPaths, + isAuthenticated: true, + ); + + expect(result.length, 1); + expect(result[0].id, 'drawing-1'); + verify(() => mockRepository.deleteDrawing('drawing-2')).called(1); + verifyNever(() => mockRepository.deleteDrawing('drawing-1')); + }); + + test('adds new paths from eraser split', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(35.5, 139.5)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final drawing1 = DrawingData( + id: 'drawing-1', + userId: 'user-123', + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final oldDrawings = [drawing1]; + + // Original path is removed, two new split paths are created + final splitPath1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final splitPath2 = DrawingPath( + points: [LatLng(35.5, 139.5)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final newPaths = [splitPath1, splitPath2]; + + final serverDrawingSplit1 = DrawingData( + id: 'server-split-1', + userId: 'user-123', + mapId: null, + path: splitPath1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final serverDrawingSplit2 = DrawingData( + id: 'server-split-2', + userId: 'user-123', + mapId: null, + path: splitPath2, + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('drawing-1')) + .thenAnswer((_) async {}); + when(() => mockRepository.addDrawing(splitPath1)) + .thenAnswer((_) async => serverDrawingSplit1); + when(() => mockRepository.addDrawing(splitPath2)) + .thenAnswer((_) async => serverDrawingSplit2); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [drawing1]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: oldDrawings, + newPaths: newPaths, + isAuthenticated: true, + ); + + expect(result.length, 2); + expect(result.map((d) => d.id).toList(), ['server-split-1', 'server-split-2']); + verify(() => mockRepository.deleteDrawing('drawing-1')).called(1); + verify(() => mockRepository.addDrawing(any())).called(2); + }); + + test('handles local drawings correctly when deleted', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final localDrawing = DrawingData( + id: 'local-1', + userId: null, + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ); + final oldDrawings = [localDrawing]; + final newPaths = []; + + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => [localDrawing]); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: oldDrawings, + newPaths: newPaths, + isAuthenticated: false, + ); + + expect(result, isEmpty); + verify(() => mockStorage.setLocalDrawings([])).called(1); + verifyNever(() => mockRepository.deleteDrawing(any())); + }); + + test('persists to local storage when not authenticated', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final drawing1 = DrawingData( + id: 'drawing-1', + userId: 'user-123', + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + ); + + final newPath = DrawingPath( + points: [LatLng(36.0, 140.0)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + + // deleteDrawing checks isOnline even when not authenticated for non-local drawings + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => false); + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [drawing1]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: [drawing1], + newPaths: [newPath], + isAuthenticated: false, + ); + + expect(result.length, 1); + expect(result[0].isLocal, true); + verifyNever(() => mockRepository.addDrawing(any())); + }); + + test('handles offline scenario with pending deletions', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final drawing1 = DrawingData( + id: 'drawing-1', + userId: 'user-123', + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => false); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [drawing1]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: [drawing1], + newPaths: [], + isAuthenticated: true, + ); + + expect(result, isEmpty); + verify(() => mockStorage.setPendingDeletions(['drawing-1'])).called(1); + verifyNever(() => mockRepository.deleteDrawing(any())); + }); + + test('preserves paths by object identity', () async { + final path1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final path2 = DrawingPath( + points: [LatLng(36.0, 140.0)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + final drawing1 = DrawingData( + id: 'drawing-1', + userId: 'user-123', + mapId: null, + path: path1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final drawing2 = DrawingData( + id: 'drawing-2', + userId: 'user-123', + mapId: null, + path: path2, + createdAt: DateTime.utc(2024, 1, 15), + ); + + // Same path object references maintained + final newPaths = [path1, path2]; + + final result = await service.replaceDrawings( + oldDrawings: [drawing1, drawing2], + newPaths: newPaths, + isAuthenticated: true, + ); + + expect(result.length, 2); + expect(result[0].id, 'drawing-1'); + expect(result[1].id, 'drawing-2'); + verifyNever(() => mockRepository.deleteDrawing(any())); + verifyNever(() => mockRepository.addDrawing(any())); + }); + }); + + group('clearIfUserChanged', () { + test('clears all when user changes', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => 'old-user'); + when(() => mockStorage.clearAll()) + .thenAnswer((_) async {}); + when(() => mockStorage.setLastUserId('new-user')) + .thenAnswer((_) async {}); + + await service.clearIfUserChanged('new-user'); + + verify(() => mockStorage.clearAll()).called(1); + verify(() => mockStorage.setLastUserId('new-user')).called(1); + }); + + test('does not clear when user is same', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => 'same-user'); + when(() => mockStorage.setLastUserId('same-user')) + .thenAnswer((_) async {}); + + await service.clearIfUserChanged('same-user'); + + verifyNever(() => mockStorage.clearAll()); + verify(() => mockStorage.setLastUserId('same-user')).called(1); + }); + + test('does not clear when no previous user', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => null); + when(() => mockStorage.setLastUserId('new-user')) + .thenAnswer((_) async {}); + + await service.clearIfUserChanged('new-user'); + + verifyNever(() => mockStorage.clearAll()); + verify(() => mockStorage.setLastUserId('new-user')).called(1); + }); + }); +} From 7b9104a58c9d541fdbd621d579ea6fa44dc8ea5c Mon Sep 17 00:00:00 2001 From: nakomochi Date: Wed, 11 Mar 2026 21:48:35 +0900 Subject: [PATCH 2/5] fix: undo erase --- .../map/providers/drawing_provider.dart | 78 +++++- .../map/providers/drawing_provider_test.dart | 226 ++++++++++++++++++ 2 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 test/features/map/providers/drawing_provider_test.dart diff --git a/lib/features/map/providers/drawing_provider.dart b/lib/features/map/providers/drawing_provider.dart index 29d9487..9f7c905 100644 --- a/lib/features/map/providers/drawing_provider.dart +++ b/lib/features/map/providers/drawing_provider.dart @@ -48,6 +48,9 @@ class DrawingState { /// Original drawings saved at eraser operation start (for persistence) final List? eraserOriginalDrawings; + /// Stack of previous drawing states for undo functionality + final List> undoStack; + DrawingState({ required this.drawingDataList, required this.selectedColor, @@ -56,12 +59,37 @@ class DrawingState { this.isEraserMode = false, this.eraserTempPaths, this.eraserOriginalDrawings, + this.undoStack = const [], }); + /// Returns true if eraser operation is currently in progress + bool get isEraserOperationActive => eraserTempPaths != null; + /// Returns eraser temp paths if in eraser operation, otherwise actual paths List get paths => eraserTempPaths ?? drawingDataList.map((d) => d.path).toList(); + /// Returns true if there are items in the undo stack + bool get canUndo => undoStack.isNotEmpty; + + /// Push current drawingDataList to undo stack (call before modifying) + DrawingState pushUndo() { + return copyWith( + undoStack: [...undoStack, drawingDataList], + ); + } + + /// Pop from undo stack and return (newState, restoredDrawings) + /// Returns (newState, null) if stack is empty + (DrawingState, List?) popUndo() { + if (undoStack.isEmpty) { + return (this, null); + } + final newStack = List>.from(undoStack); + final restored = newStack.removeLast(); + return (copyWith(undoStack: newStack), restored); + } + DrawingState copyWith({ List? drawingDataList, Color? selectedColor, @@ -70,6 +98,7 @@ class DrawingState { bool? isEraserMode, List? Function()? eraserTempPaths, List? Function()? eraserOriginalDrawings, + List>? undoStack, }) { return DrawingState( drawingDataList: drawingDataList ?? this.drawingDataList, @@ -82,6 +111,7 @@ class DrawingState { eraserOriginalDrawings: eraserOriginalDrawings != null ? eraserOriginalDrawings() : this.eraserOriginalDrawings, + undoStack: undoStack ?? this.undoStack, ); } } @@ -172,10 +202,12 @@ class DrawingNotifier extends AsyncNotifier { final isAuthenticated = ref.read(isAuthenticatedProvider); + // Push current state to undo stack before adding + final withUndo = current.pushUndo(); final optimisticDrawing = DrawingData.local(path); state = AsyncValue.data( - current.copyWith( - drawingDataList: [...current.drawingDataList, optimisticDrawing], + withUndo.copyWith( + drawingDataList: [...withUndo.drawingDataList, optimisticDrawing], ), ); @@ -239,12 +271,15 @@ class DrawingNotifier extends AsyncNotifier { // If no eraser operation was active, nothing to do if (originalDrawings == null || tempPaths == null) return; + // Push original state to undo stack before eraser changes + final withUndo = current.pushUndo(); + // Update drawingDataList with temp paths AND clear eraser state atomically // This prevents UI from showing old drawings during persistence final tempDrawingDataList = tempPaths.map((p) => DrawingData.local(p)).toList(); state = AsyncValue.data( - current.copyWith( + withUndo.copyWith( drawingDataList: tempDrawingDataList, eraserOriginalDrawings: () => null, eraserTempPaths: () => null, @@ -303,8 +338,41 @@ class DrawingNotifier extends AsyncNotifier { Future undo() async { final current = state.valueOrNull; - if (current != null && current.drawingDataList.isNotEmpty) { - await removePathAt(current.drawingDataList.length - 1); + if (current == null) return; + // Skip undo during eraser operation to prevent inconsistency + if (current.isEraserOperationActive) return; + if (!current.canUndo) return; + + final (newState, restoredDrawings) = current.popUndo(); + if (restoredDrawings == null) return; + + // Update UI immediately + state = AsyncValue.data( + newState.copyWith(drawingDataList: restoredDrawings), + ); + + // Persist the restored state + try { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(drawingSyncServiceProvider.future); + + // Replace current drawings with restored ones + final persistedDrawings = await syncService.replaceDrawings( + oldDrawings: current.drawingDataList, + newPaths: restoredDrawings.map((d) => d.path).toList(), + isAuthenticated: isAuthenticated, + ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith(drawingDataList: persistedDrawings), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to persist undo: $e\n$st'); + } } } diff --git a/test/features/map/providers/drawing_provider_test.dart b/test/features/map/providers/drawing_provider_test.dart new file mode 100644 index 0000000..041f5a9 --- /dev/null +++ b/test/features/map/providers/drawing_provider_test.dart @@ -0,0 +1,226 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/providers/drawing_provider.dart'; + +void main() { + group('DrawingState', () { + final testPath1 = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(35.1, 139.1)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final testPath2 = DrawingPath( + points: [LatLng(36.0, 140.0), LatLng(36.1, 140.1)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + final testPath3 = DrawingPath( + points: [LatLng(37.0, 141.0), LatLng(37.1, 141.1)], + color: const Color(0xFF0000FF), + strokeWidth: 3.0, + ); + + DrawingData createTestDrawing(String id, DrawingPath path) { + return DrawingData( + id: id, + userId: 'user-123', + mapId: null, + path: path, + createdAt: DateTime.utc(2024, 1, 15), + ); + } + + group('isEraserOperationActive', () { + test('returns false when eraserTempPaths is null', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [drawing1], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + expect(state.isEraserOperationActive, false); + }); + + test('returns true when eraserTempPaths is not null', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [drawing1], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + isEraserMode: true, + eraserOriginalDrawings: [drawing1], + eraserTempPaths: [testPath1], + ); + + expect(state.isEraserOperationActive, true); + }); + }); + + group('paths getter', () { + test('returns eraserTempPaths when eraser operation is active', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + + final state = DrawingState( + drawingDataList: [drawing1, drawing2], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + isEraserMode: true, + eraserOriginalDrawings: [drawing1, drawing2], + eraserTempPaths: [testPath1], // Only path1 remains after erasing + ); + + expect(state.paths.length, 1); + expect(identical(state.paths[0], testPath1), true); + }); + + test('returns drawingDataList paths when eraser operation is not active', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + + final state = DrawingState( + drawingDataList: [drawing1, drawing2], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + expect(state.paths.length, 2); + }); + }); + + group('undoStack', () { + test('canUndo returns false when undoStack is empty', () { + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + expect(state.canUndo, false); + }); + + test('canUndo returns true when undoStack has items', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + undoStack: [ + [drawing1] + ], + ); + + expect(state.canUndo, true); + }); + + test('pushUndo adds current drawingDataList to stack', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + + final state = DrawingState( + drawingDataList: [drawing1, drawing2], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + final newState = state.pushUndo(); + + expect(newState.undoStack.length, 1); + expect(newState.undoStack[0].length, 2); + expect(newState.undoStack[0][0].id, 'drawing-1'); + }); + + test('popUndo restores previous drawingDataList', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + final drawing3 = createTestDrawing('drawing-3', testPath3); + + // State after eraser: only drawing3 remains, but undo stack has original + final state = DrawingState( + drawingDataList: [drawing3], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + undoStack: [ + [drawing1, drawing2] + ], + ); + + final (newState, restoredDrawings) = state.popUndo(); + + expect(newState.undoStack.length, 0); + expect(restoredDrawings!.length, 2); + expect(restoredDrawings[0].id, 'drawing-1'); + expect(restoredDrawings[1].id, 'drawing-2'); + }); + + test('popUndo returns null when stack is empty', () { + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + final (newState, restoredDrawings) = state.popUndo(); + + expect(newState.undoStack.length, 0); + expect(restoredDrawings, null); + }); + + test('multiple pushUndo and popUndo work in LIFO order', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + final drawing3 = createTestDrawing('drawing-3', testPath3); + + // Start with drawing1 + var state = DrawingState( + drawingDataList: [drawing1], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + // Push (save drawing1), then change to drawing1+drawing2 + state = state.pushUndo().copyWith(drawingDataList: [drawing1, drawing2]); + + // Push (save drawing1+drawing2), then change to drawing3 only + state = state.pushUndo().copyWith(drawingDataList: [drawing3]); + + expect(state.undoStack.length, 2); + expect(state.drawingDataList.length, 1); + + // Pop: should get drawing1+drawing2 + var result = state.popUndo(); + state = result.$1.copyWith(drawingDataList: result.$2); + expect(state.drawingDataList.length, 2); + expect(state.drawingDataList[0].id, 'drawing-1'); + + // Pop: should get drawing1 + result = state.popUndo(); + state = result.$1.copyWith(drawingDataList: result.$2); + expect(state.drawingDataList.length, 1); + expect(state.drawingDataList[0].id, 'drawing-1'); + + // Pop: stack empty, returns null + result = state.popUndo(); + expect(result.$2, null); + }); + }); + }); +} From 362f0cb339737eedcf74fb9b9da4196a549b7ad8 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Wed, 11 Mar 2026 22:52:23 +0900 Subject: [PATCH 3/5] feat: redo and asynclock --- .../map/presentation/widgets/controls.dart | 24 +- .../map/providers/drawing_provider.dart | 300 ++++++---- .../map/services/drawing_sync_service.dart | 64 ++- .../map/providers/drawing_notifier_test.dart | 537 ++++++++++++++++++ .../map/providers/drawing_provider_test.dart | 132 +++++ .../services/drawing_sync_service_test.dart | 193 +++++++ 6 files changed, 1128 insertions(+), 122 deletions(-) create mode 100644 test/features/map/providers/drawing_notifier_test.dart diff --git a/lib/features/map/presentation/widgets/controls.dart b/lib/features/map/presentation/widgets/controls.dart index 39ac8a4..f19907d 100644 --- a/lib/features/map/presentation/widgets/controls.dart +++ b/lib/features/map/presentation/widgets/controls.dart @@ -59,14 +59,32 @@ class Controls extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // UndoButtonBar OverflowBar( alignment: MainAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.undo_rounded), + icon: Icon( + Icons.undo_rounded, + color: drawingState?.canUndo == true + ? Colors.black + : Colors.grey, + ), tooltip: '元に戻す', - onPressed: () => drawingNotifier.undo(), + onPressed: drawingState?.canUndo == true + ? () => drawingNotifier.undo() + : null, + ), + IconButton( + icon: Icon( + Icons.redo_rounded, + color: drawingState?.canRedo == true + ? Colors.black + : Colors.grey, + ), + tooltip: 'やり直す', + onPressed: drawingState?.canRedo == true + ? () => drawingNotifier.redo() + : null, ), IconButton( icon: Icon( diff --git a/lib/features/map/providers/drawing_provider.dart b/lib/features/map/providers/drawing_provider.dart index 9f7c905..4043087 100644 --- a/lib/features/map/providers/drawing_provider.dart +++ b/lib/features/map/providers/drawing_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -51,6 +53,9 @@ class DrawingState { /// Stack of previous drawing states for undo functionality final List> undoStack; + /// Stack of undone states for redo functionality + final List> redoStack; + DrawingState({ required this.drawingDataList, required this.selectedColor, @@ -60,6 +65,7 @@ class DrawingState { this.eraserTempPaths, this.eraserOriginalDrawings, this.undoStack = const [], + this.redoStack = const [], }); /// Returns true if eraser operation is currently in progress @@ -72,6 +78,9 @@ class DrawingState { /// Returns true if there are items in the undo stack bool get canUndo => undoStack.isNotEmpty; + /// Returns true if there are items in the redo stack + bool get canRedo => redoStack.isNotEmpty; + /// Push current drawingDataList to undo stack (call before modifying) DrawingState pushUndo() { return copyWith( @@ -90,6 +99,29 @@ class DrawingState { return (copyWith(undoStack: newStack), restored); } + /// Push current drawingDataList to redo stack (call before undo restores) + DrawingState pushRedo() { + return copyWith( + redoStack: [...redoStack, drawingDataList], + ); + } + + /// Pop from redo stack and return (newState, restoredDrawings) + /// Returns (newState, null) if stack is empty + (DrawingState, List?) popRedo() { + if (redoStack.isEmpty) { + return (this, null); + } + final newStack = List>.from(redoStack); + final restored = newStack.removeLast(); + return (copyWith(redoStack: newStack), restored); + } + + /// Clear the redo stack (call when new operation is performed) + DrawingState clearRedoStack() { + return copyWith(redoStack: []); + } + DrawingState copyWith({ List? drawingDataList, Color? selectedColor, @@ -99,6 +131,7 @@ class DrawingState { List? Function()? eraserTempPaths, List? Function()? eraserOriginalDrawings, List>? undoStack, + List>? redoStack, }) { return DrawingState( drawingDataList: drawingDataList ?? this.drawingDataList, @@ -112,16 +145,39 @@ class DrawingState { ? eraserOriginalDrawings() : this.eraserOriginalDrawings, undoStack: undoStack ?? this.undoStack, + redoStack: redoStack ?? this.redoStack, ); } } +/// Simple async lock to serialize operations +class _AsyncLock { + Future? _lastOperation; + + Future synchronized(Future Function() operation) async { + final previous = _lastOperation; + final completer = Completer(); + _lastOperation = completer.future; + + try { + if (previous != null) { + await previous; + } + return await operation(); + } finally { + completer.complete(); + } + } +} + final drawingProvider = AsyncNotifierProvider(() { return DrawingNotifier(); }); class DrawingNotifier extends AsyncNotifier { + final _lock = _AsyncLock(); + @override Future build() async { ref.listen(sessionProvider, (prev, next) { @@ -197,42 +253,43 @@ class DrawingNotifier extends AsyncNotifier { } Future addPath(DrawingPath path) async { - final current = state.valueOrNull; - if (current == null) return; - - final isAuthenticated = ref.read(isAuthenticatedProvider); + return _lock.synchronized(() async { + final current = state.valueOrNull; + if (current == null) return; - // Push current state to undo stack before adding - final withUndo = current.pushUndo(); - final optimisticDrawing = DrawingData.local(path); - state = AsyncValue.data( - withUndo.copyWith( - drawingDataList: [...withUndo.drawingDataList, optimisticDrawing], - ), - ); + final isAuthenticated = ref.read(isAuthenticatedProvider); - try { - final syncService = await ref.read(drawingSyncServiceProvider.future); - final realDrawing = await syncService.addDrawing( - path: path, - isAuthenticated: isAuthenticated, + final withUndo = current.pushUndo().clearRedoStack(); + final optimisticDrawing = DrawingData.local(path); + state = AsyncValue.data( + withUndo.copyWith( + drawingDataList: [...withUndo.drawingDataList, optimisticDrawing], + ), ); - final updated = state.valueOrNull; - if (updated != null) { - state = AsyncValue.data( - updated.copyWith( - drawingDataList: updated.drawingDataList - .map((d) => d.id == optimisticDrawing.id ? realDrawing : d) - .toList(), - ), + try { + final syncService = await ref.read(drawingSyncServiceProvider.future); + final realDrawing = await syncService.addDrawing( + path: path, + isAuthenticated: isAuthenticated, ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith( + drawingDataList: updated.drawingDataList + .map((d) => d.id == optimisticDrawing.id ? realDrawing : d) + .toList(), + ), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to add drawing: $e\n$st'); + } } - } catch (e, st) { - if (kDebugMode) { - debugPrint('Failed to add drawing: $e\n$st'); - } - } + }); } // ============ Eraser Operation Methods ============ @@ -262,53 +319,54 @@ class DrawingNotifier extends AsyncNotifier { /// Call when eraser operation ends (on pan end) - persists changes Future finishEraserOperation() async { - final current = state.valueOrNull; - if (current == null) return; - - final originalDrawings = current.eraserOriginalDrawings; - final tempPaths = current.eraserTempPaths; - - // If no eraser operation was active, nothing to do - if (originalDrawings == null || tempPaths == null) return; + return _lock.synchronized(() async { + final current = state.valueOrNull; + if (current == null) return; - // Push original state to undo stack before eraser changes - final withUndo = current.pushUndo(); + final originalDrawings = current.eraserOriginalDrawings; + final tempPaths = current.eraserTempPaths; - // Update drawingDataList with temp paths AND clear eraser state atomically - // This prevents UI from showing old drawings during persistence - final tempDrawingDataList = - tempPaths.map((p) => DrawingData.local(p)).toList(); - state = AsyncValue.data( - withUndo.copyWith( - drawingDataList: tempDrawingDataList, - eraserOriginalDrawings: () => null, - eraserTempPaths: () => null, - ), - ); + // If no eraser operation was active, nothing to do + if (originalDrawings == null || tempPaths == null) return; - // Persist changes in background - try { - final isAuthenticated = ref.read(isAuthenticatedProvider); - final syncService = await ref.read(drawingSyncServiceProvider.future); + final withUndo = current.pushUndo().clearRedoStack(); - final newDrawingDataList = await syncService.replaceDrawings( - oldDrawings: originalDrawings, - newPaths: tempPaths, - isAuthenticated: isAuthenticated, + // Update drawingDataList with temp paths AND clear eraser state atomically + // This prevents UI from showing old drawings during persistence + final tempDrawingDataList = + tempPaths.map((p) => DrawingData.local(p)).toList(); + state = AsyncValue.data( + withUndo.copyWith( + drawingDataList: tempDrawingDataList, + eraserOriginalDrawings: () => null, + eraserTempPaths: () => null, + ), ); - final updated = state.valueOrNull; - if (updated != null) { - state = AsyncValue.data( - updated.copyWith(drawingDataList: newDrawingDataList), + // Persist changes in background + try { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(drawingSyncServiceProvider.future); + + final newDrawingDataList = await syncService.replaceDrawings( + oldDrawings: originalDrawings, + newPaths: tempPaths, + isAuthenticated: isAuthenticated, ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith(drawingDataList: newDrawingDataList), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to persist eraser changes: $e\n$st'); + } + // On error, tempDrawingDataList is already set, so no action needed } - } catch (e, st) { - if (kDebugMode) { - debugPrint('Failed to persist eraser changes: $e\n$st'); - } - // On error, tempDrawingDataList is already set, so no action needed - } + }); } Future removePathAt(int index) async { @@ -337,43 +395,89 @@ class DrawingNotifier extends AsyncNotifier { } Future undo() async { - final current = state.valueOrNull; - if (current == null) return; - // Skip undo during eraser operation to prevent inconsistency - if (current.isEraserOperationActive) return; - if (!current.canUndo) return; + return _lock.synchronized(() async { + final current = state.valueOrNull; + if (current == null) return; + // Skip undo during eraser operation to prevent inconsistency + if (current.isEraserOperationActive) return; + if (!current.canUndo) return; - final (newState, restoredDrawings) = current.popUndo(); - if (restoredDrawings == null) return; + // Push current to redo stack, then pop from undo stack + final withRedo = current.pushRedo(); + final (newState, restoredDrawings) = withRedo.popUndo(); + if (restoredDrawings == null) return; - // Update UI immediately - state = AsyncValue.data( - newState.copyWith(drawingDataList: restoredDrawings), - ); + // Update UI immediately + state = AsyncValue.data( + newState.copyWith(drawingDataList: restoredDrawings), + ); - // Persist the restored state - try { - final isAuthenticated = ref.read(isAuthenticatedProvider); - final syncService = await ref.read(drawingSyncServiceProvider.future); + // Persist the restored state + try { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(drawingSyncServiceProvider.future); - // Replace current drawings with restored ones - final persistedDrawings = await syncService.replaceDrawings( - oldDrawings: current.drawingDataList, - newPaths: restoredDrawings.map((d) => d.path).toList(), - isAuthenticated: isAuthenticated, + final persistedDrawings = await syncService.replaceDrawings( + oldDrawings: current.drawingDataList, + newDrawings: restoredDrawings, + isAuthenticated: isAuthenticated, + ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith(drawingDataList: persistedDrawings), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to persist undo: $e\n$st'); + } + } + }); + } + + Future redo() async { + return _lock.synchronized(() async { + final current = state.valueOrNull; + if (current == null) return; + // Skip redo during eraser operation to prevent inconsistency + if (current.isEraserOperationActive) return; + if (!current.canRedo) return; + + // Push current to undo stack, then pop from redo stack + final withUndo = current.pushUndo(); + final (newState, restoredDrawings) = withUndo.popRedo(); + if (restoredDrawings == null) return; + + // Update UI immediately + state = AsyncValue.data( + newState.copyWith(drawingDataList: restoredDrawings), ); - final updated = state.valueOrNull; - if (updated != null) { - state = AsyncValue.data( - updated.copyWith(drawingDataList: persistedDrawings), + // Persist the restored state + try { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(drawingSyncServiceProvider.future); + + final persistedDrawings = await syncService.replaceDrawings( + oldDrawings: current.drawingDataList, + newDrawings: restoredDrawings, + isAuthenticated: isAuthenticated, ); + + final updated = state.valueOrNull; + if (updated != null) { + state = AsyncValue.data( + updated.copyWith(drawingDataList: persistedDrawings), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to persist redo: $e\n$st'); + } } - } catch (e, st) { - if (kDebugMode) { - debugPrint('Failed to persist undo: $e\n$st'); - } - } + }); } void selectColor(Color color) { diff --git a/lib/features/map/services/drawing_sync_service.dart b/lib/features/map/services/drawing_sync_service.dart index 5846a48..fc416fd 100644 --- a/lib/features/map/services/drawing_sync_service.dart +++ b/lib/features/map/services/drawing_sync_service.dart @@ -100,39 +100,61 @@ class DrawingSyncService { await storage.setPendingDeletions([...pendingDeletions, drawingId]); } - /// Replaces old drawings with new paths, handling deletions and additions. - /// Uses object identity (identical) to determine if a path was preserved. + /// Replaces old drawings with new drawings/paths, handling deletions and additions. + /// + /// When [newDrawings] is provided, uses ID-based comparison (for undo/redo). + /// When [newPaths] is provided, uses object identity (for eraser). Future> replaceDrawings({ required List oldDrawings, - required List newPaths, + List? newPaths, + List? newDrawings, required bool isAuthenticated, }) async { + assert(newPaths != null || newDrawings != null); + final result = []; - final processedOldDrawings = {}; - - for (final newPath in newPaths) { - DrawingData? existingData; - for (final old in oldDrawings) { - if (identical(old.path, newPath)) { - existingData = old; - break; + final processedOldIds = {}; + + if (newDrawings != null) { + final oldById = {for (final d in oldDrawings) d.id: d}; + + for (final newDrawing in newDrawings) { + if (oldById.containsKey(newDrawing.id)) { + result.add(oldById[newDrawing.id]!); + processedOldIds.add(newDrawing.id); + } else { + final added = await addDrawing( + path: newDrawing.path, + isAuthenticated: isAuthenticated, + ); + result.add(added); } } + } else { + for (final newPath in newPaths!) { + DrawingData? existingData; + for (final old in oldDrawings) { + if (identical(old.path, newPath)) { + existingData = old; + break; + } + } - if (existingData != null) { - result.add(existingData); - processedOldDrawings.add(existingData); - } else { - final newDrawing = await addDrawing( - path: newPath, - isAuthenticated: isAuthenticated, - ); - result.add(newDrawing); + if (existingData != null) { + result.add(existingData); + processedOldIds.add(existingData.id); + } else { + final newDrawing = await addDrawing( + path: newPath, + isAuthenticated: isAuthenticated, + ); + result.add(newDrawing); + } } } for (final oldDrawing in oldDrawings) { - if (!processedOldDrawings.contains(oldDrawing)) { + if (!processedOldIds.contains(oldDrawing.id)) { await deleteDrawing( drawing: oldDrawing, isAuthenticated: isAuthenticated, diff --git a/test/features/map/providers/drawing_notifier_test.dart b/test/features/map/providers/drawing_notifier_test.dart new file mode 100644 index 0000000..ffa815b --- /dev/null +++ b/test/features/map/providers/drawing_notifier_test.dart @@ -0,0 +1,537 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/data/drawing_repository.dart'; +import 'package:memomap/features/map/data/local_drawing_storage.dart'; +import 'package:memomap/features/map/data/network_checker.dart'; +import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/providers/drawing_provider.dart'; +import 'package:memomap/features/map/services/drawing_sync_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockLocalDrawingStorage extends Mock implements LocalDrawingStorageBase {} + +class MockNetworkChecker extends Mock implements NetworkCheckerBase {} + +class MockDrawingSyncService extends Mock implements DrawingSyncService {} + +void main() { + group('DrawingNotifier race conditions', () { + late ProviderContainer container; + late MockDrawingSyncService mockSyncService; + + final testPath1 = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(35.1, 139.1)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + + final testPath2 = DrawingPath( + points: [LatLng(36.0, 140.0), LatLng(36.1, 140.1)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + + DrawingData createDrawing(String id, DrawingPath path) { + return DrawingData( + id: id, + userId: 'user-123', + mapId: null, + path: path, + createdAt: DateTime.utc(2024, 1, 15), + ); + } + + setUpAll(() { + registerFallbackValue(testPath1); + registerFallbackValue([]); + registerFallbackValue(DrawingData( + id: 'fallback', + userId: 'user', + mapId: null, + path: testPath1, + createdAt: DateTime.utc(2024, 1, 1), + )); + }); + + setUp(() { + mockSyncService = MockDrawingSyncService(); + + // Default mock behaviors + when(() => mockSyncService.getAllDrawings()) + .thenAnswer((_) async => []); + when(() => mockSyncService.clearIfUserChanged(any())) + .thenAnswer((_) async {}); + }); + + tearDown(() { + container.dispose(); + }); + + ProviderContainer createContainer({ + List initialDrawings = const [], + }) { + when(() => mockSyncService.getAllDrawings()) + .thenAnswer((_) async => initialDrawings); + + return ProviderContainer( + overrides: [ + drawingSyncServiceProvider.overrideWith( + (ref) async => mockSyncService, + ), + sessionProvider.overrideWith( + (ref) async => null, + ), + isAuthenticatedProvider.overrideWith((ref) => false), + ], + ); + } + + test( + 'undo after addPath sync is properly serialized', + () async { + // With async lock: + // 1. addPath acquires lock, adds drawing B + // 2. undo waits for lock, then runs after addPath completes + // 3. undo properly deletes B from server via replaceDrawings + // Result: server and client are consistent + + final drawingA = createDrawing('a', testPath1); + container = createContainer(initialDrawings: [drawingA]); + + await container.read(drawingProvider.future); + + final notifier = container.read(drawingProvider.notifier); + + // Track server operations + final serverDrawings = {'a'}; + + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async { + final drawing = createDrawing('b', testPath2); + serverDrawings.add(drawing.id); + return drawing; + }); + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((invocation) async { + final oldDrawings = + invocation.namedArguments[#oldDrawings] as List; + final newDrawings = + invocation.namedArguments[#newDrawings] as List; + + final newIds = newDrawings.map((d) => d.id).toSet(); + for (final old in oldDrawings) { + if (!newIds.contains(old.id) && !old.isLocal) { + serverDrawings.remove(old.id); + } + } + return newDrawings; + }); + + // Start addPath and undo concurrently + // Lock ensures they run sequentially: addPath then undo + final addPathFuture = notifier.addPath(testPath2); + final undoFuture = notifier.undo(); + + await Future.wait([addPathFuture, undoFuture]); + + // Client state + final state = container.read(drawingProvider).valueOrNull!; + final clientIds = state.drawingDataList.map((d) => d.id).toSet(); + + // With proper operation locking: + // - addPath completes first (adds B to server) + // - undo runs after (deletes B from server via replaceDrawings) + // Server and client both have only 'a' + expect( + serverDrawings.difference(clientIds), + isEmpty, + reason: 'Server should not have drawings that client does not have', + ); + expect(clientIds, {'a'}); + expect(serverDrawings, {'a'}); + }, + ); + + test( + 'undo during finishEraserOperation causes server inconsistency', + () async { + final drawingA = createDrawing('a', testPath1); + container = createContainer(initialDrawings: [drawingA]); + + await container.read(drawingProvider.future); + final notifier = container.read(drawingProvider.notifier); + + final serverDrawings = {'a'}; + final eraserCompleter = Completer>(); + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newPaths: any(named: 'newPaths'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) => eraserCompleter.future); + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((invocation) async { + final newDrawings = + invocation.namedArguments[#newDrawings] as List; + return newDrawings; + }); + + // Start eraser operation + notifier.startEraserOperation(); + final splitPath = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + notifier.updateEraserPaths([splitPath]); + + // Start finishEraserOperation (will wait on eraserCompleter) + final eraserFuture = notifier.finishEraserOperation(); + await Future.delayed(Duration.zero); + + // Undo is blocked during eraser operation + // This test verifies the block works + final stateBeforeUndo = container.read(drawingProvider).valueOrNull!; + + // Complete eraser + final splitDrawing = createDrawing('split-1', splitPath); + eraserCompleter.complete([splitDrawing]); + await eraserFuture; + + // Now undo should work + await notifier.undo(); + + final state = container.read(drawingProvider).valueOrNull!; + expect(state.drawingDataList.length, 1); + expect(state.drawingDataList[0].id, 'a'); + }, + ); + + test( + 'addPath during undo causes state overwrite', + () async { + final drawingA = createDrawing('a', testPath1); + final drawingB = createDrawing('b', testPath2); + container = createContainer(initialDrawings: [drawingA, drawingB]); + + await container.read(drawingProvider.future); + final notifier = container.read(drawingProvider.notifier); + + // Add C first to have something to undo + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async => createDrawing('c', testPath1)); + + await notifier.addPath(testPath1); + + // Now set up delayed undo and immediate addPath + final undoCompleter = Completer>(); + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) => undoCompleter.future); + + final addPathCompleter = Completer(); + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) => addPathCompleter.future); + + // Start undo (will wait on undoCompleter) + final undoFuture = notifier.undo(); + await Future.delayed(Duration.zero); + + // Start addPath during undo + final addPathFuture = notifier.addPath(testPath2); + await Future.delayed(Duration.zero); + + // Complete addPath first + final drawingD = createDrawing('d', testPath2); + addPathCompleter.complete(drawingD); + + // Then complete undo + undoCompleter.complete([drawingA, drawingB]); + + await Future.wait([undoFuture, addPathFuture]); + + final state = container.read(drawingProvider).valueOrNull!; + + // With proper locking: + // - Either undo completes first, then addPath adds D: [a, b, d] + // - Or addPath completes first, then undo reverts: [a, b] + // Without locking, state is unpredictable + expect( + state.drawingDataList.map((d) => d.id).toSet(), + anyOf( + equals({'a', 'b', 'd'}), + equals({'a', 'b'}), + ), + reason: 'State should be consistent (either undone or with new drawing)', + ); + }, + ); + + test( + 'multiple concurrent addPath calls should preserve all drawings', + () async { + container = createContainer(); + await container.read(drawingProvider.future); + + final notifier = container.read(drawingProvider.notifier); + + final completer1 = Completer(); + final completer2 = Completer(); + final completer3 = Completer(); + var callCount = 0; + + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) { + callCount++; + switch (callCount) { + case 1: + return completer1.future; + case 2: + return completer2.future; + default: + return completer3.future; + } + }); + + // Start three addPath calls + final future1 = notifier.addPath(testPath1); + await Future.delayed(Duration.zero); + final future2 = notifier.addPath(testPath2); + await Future.delayed(Duration.zero); + final path3 = DrawingPath( + points: [LatLng(37.0, 141.0)], + color: const Color(0xFF0000FF), + strokeWidth: 3.0, + ); + final future3 = notifier.addPath(path3); + + // Complete in reverse order + completer3.complete(createDrawing('c', path3)); + await Future.delayed(Duration.zero); + completer2.complete(createDrawing('b', testPath2)); + await Future.delayed(Duration.zero); + completer1.complete(createDrawing('a', testPath1)); + + await Future.wait([future1, future2, future3]); + + final state = container.read(drawingProvider).valueOrNull!; + + // All three drawings should be present + expect( + state.drawingDataList.length, + 3, + reason: 'All concurrent addPath calls should result in drawings', + ); + expect( + state.drawingDataList.map((d) => d.id).toSet(), + {'a', 'b', 'c'}, + ); + }, + ); + + test( + 'concurrent undo calls should not corrupt stack', + () async { + final drawingA = createDrawing('a', testPath1); + container = createContainer(initialDrawings: [drawingA]); + + await container.read(drawingProvider.future); + final notifier = container.read(drawingProvider.notifier); + + // Add B and C to have undo history + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((inv) async { + final path = inv.namedArguments[#path] as DrawingPath; + return createDrawing( + path == testPath1 ? 'b' : 'c', + path, + ); + }); + + await notifier.addPath(testPath1); + await notifier.addPath(testPath2); + + // Set up delayed replaceDrawings + final completer1 = Completer>(); + final completer2 = Completer>(); + var undoCallCount = 0; + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) { + undoCallCount++; + if (undoCallCount == 1) { + return completer1.future; + } + return completer2.future; + }); + + // Start two undo calls concurrently + final undo1 = notifier.undo(); + await Future.delayed(Duration.zero); + final undo2 = notifier.undo(); + + // Complete both + completer1.complete([drawingA, createDrawing('b', testPath1)]); + completer2.complete([drawingA]); + + await Future.wait([undo1, undo2]); + + final state = container.read(drawingProvider).valueOrNull!; + + // With proper locking, should undo twice: [a, b, c] -> [a, b] -> [a] + // Without locking, behavior is undefined + expect( + state.drawingDataList.length, + lessThanOrEqualTo(2), + reason: 'At least one undo should have taken effect', + ); + }, + ); + + test( + 'undo during redo causes stack inconsistency', + () async { + final drawingA = createDrawing('a', testPath1); + container = createContainer(initialDrawings: [drawingA]); + + await container.read(drawingProvider.future); + final notifier = container.read(drawingProvider.notifier); + + // Add B + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async => createDrawing('b', testPath2)); + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((inv) async { + return inv.namedArguments[#newDrawings] as List; + }); + + await notifier.addPath(testPath2); + await notifier.undo(); + + // Now we can redo + var state = container.read(drawingProvider).valueOrNull!; + expect(state.canRedo, true); + + // Set up delayed redo + final redoCompleter = Completer>(); + final undoCompleter = Completer>(); + var callCount = 0; + + when(() => mockSyncService.replaceDrawings( + oldDrawings: any(named: 'oldDrawings'), + newDrawings: any(named: 'newDrawings'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) { + callCount++; + if (callCount == 1) { + return redoCompleter.future; + } + return undoCompleter.future; + }); + + // Start redo + final redoFuture = notifier.redo(); + await Future.delayed(Duration.zero); + + // Start undo during redo + final undoFuture = notifier.undo(); + + // Complete both + redoCompleter.complete([drawingA, createDrawing('b', testPath2)]); + undoCompleter.complete([drawingA]); + + await Future.wait([redoFuture, undoFuture]); + + state = container.read(drawingProvider).valueOrNull!; + + // State should be consistent + expect( + state.drawingDataList.isNotEmpty, + true, + reason: 'State should not be corrupted', + ); + }, + ); + + test( + 'concurrent addPath calls should be serialized', + () async { + container = createContainer(); + await container.read(drawingProvider.future); + + final notifier = container.read(drawingProvider.notifier); + + final completer1 = Completer(); + final completer2 = Completer(); + var addDrawingCallCount = 0; + + when(() => mockSyncService.addDrawing( + path: any(named: 'path'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) { + addDrawingCallCount++; + if (addDrawingCallCount == 1) { + return completer1.future; + } else { + return completer2.future; + } + }); + + // Start two addPath calls concurrently + final future1 = notifier.addPath(testPath1); + final future2 = notifier.addPath(testPath2); + + // Complete in reverse order + completer2.complete(createDrawing('b', testPath2)); + await Future.delayed(Duration.zero); + completer1.complete(createDrawing('a', testPath1)); + + await Future.wait([future1, future2]); + + final state = container.read(drawingProvider).valueOrNull!; + + // With proper serialization, both drawings should be present + // and the order should be consistent + expect( + state.drawingDataList.length, + 2, + reason: 'Both drawings should be added', + ); + }, + ); + }); +} diff --git a/test/features/map/providers/drawing_provider_test.dart b/test/features/map/providers/drawing_provider_test.dart index 041f5a9..24a9913 100644 --- a/test/features/map/providers/drawing_provider_test.dart +++ b/test/features/map/providers/drawing_provider_test.dart @@ -222,5 +222,137 @@ void main() { expect(result.$2, null); }); }); + + group('redoStack', () { + test('canRedo returns false when redoStack is empty', () { + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + expect(state.canRedo, false); + }); + + test('canRedo returns true when redoStack has items', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + redoStack: [ + [drawing1] + ], + ); + + expect(state.canRedo, true); + }); + + test('pushRedo adds current drawingDataList to redo stack', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [drawing1], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + final newState = state.pushRedo(); + + expect(newState.redoStack.length, 1); + expect(newState.redoStack[0].length, 1); + expect(newState.redoStack[0][0].id, 'drawing-1'); + }); + + test('popRedo restores previous drawingDataList', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + + final state = DrawingState( + drawingDataList: [drawing1], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + redoStack: [ + [drawing1, drawing2] + ], + ); + + final (newState, restoredDrawings) = state.popRedo(); + + expect(newState.redoStack.length, 0); + expect(restoredDrawings!.length, 2); + expect(restoredDrawings[0].id, 'drawing-1'); + expect(restoredDrawings[1].id, 'drawing-2'); + }); + + test('popRedo returns null when stack is empty', () { + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + final (newState, restoredDrawings) = state.popRedo(); + + expect(newState.redoStack.length, 0); + expect(restoredDrawings, null); + }); + + test('clearRedoStack clears the redo stack', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + + final state = DrawingState( + drawingDataList: [], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + redoStack: [ + [drawing1] + ], + ); + + final newState = state.clearRedoStack(); + + expect(newState.redoStack.length, 0); + }); + + test('undo and redo work together', () { + final drawing1 = createTestDrawing('drawing-1', testPath1); + final drawing2 = createTestDrawing('drawing-2', testPath2); + + // Start: [drawing1, drawing2] + var state = DrawingState( + drawingDataList: [drawing1, drawing2], + selectedColor: const Color(0xFFFF0000), + strokeWidth: 3.0, + isDrawingMode: true, + ); + + // Add drawing3: push to undo, change state + state = state.pushUndo().copyWith(drawingDataList: [drawing1, drawing2, createTestDrawing('drawing-3', testPath3)]); + expect(state.drawingDataList.length, 3); + expect(state.undoStack.length, 1); + + // Undo: push current to redo, pop from undo + final (afterPop, restored) = state.popUndo(); + state = afterPop.pushRedo().copyWith(drawingDataList: restored); + expect(state.drawingDataList.length, 2); + expect(state.undoStack.length, 0); + expect(state.redoStack.length, 1); + + // Redo: push current to undo, pop from redo + final (afterRedoPop, redoRestored) = state.popRedo(); + state = afterRedoPop.pushUndo().copyWith(drawingDataList: redoRestored); + expect(state.drawingDataList.length, 3); + expect(state.undoStack.length, 1); + expect(state.redoStack.length, 0); + }); + }); }); } diff --git a/test/features/map/services/drawing_sync_service_test.dart b/test/features/map/services/drawing_sync_service_test.dart index 5f83476..2df855e 100644 --- a/test/features/map/services/drawing_sync_service_test.dart +++ b/test/features/map/services/drawing_sync_service_test.dart @@ -628,6 +628,199 @@ void main() { verifyNever(() => mockRepository.deleteDrawing(any())); verifyNever(() => mockRepository.addDrawing(any())); }); + + test('preserves drawings by ID even with different path objects', () async { + // Simulates undo after server sync: same ID but different path objects + final pathB1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final pathB2 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + + final drawingB_old = DrawingData( + id: 'b', + userId: 'user-123', + mapId: null, + path: pathB1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final drawingB_new = DrawingData( + id: 'b', + userId: 'user-123', + mapId: null, + path: pathB2, + createdAt: DateTime.utc(2024, 1, 15), + ); + + final result = await service.replaceDrawings( + oldDrawings: [drawingB_old], + newDrawings: [drawingB_new], + isAuthenticated: true, + ); + + // Same ID: should NOT add or delete + verifyNever(() => mockRepository.addDrawing(any())); + verifyNever(() => mockRepository.deleteDrawing(any())); + expect(result.length, 1); + expect(result[0].id, 'b'); + }); + + test('undo after eraser: deletes split drawings and re-adds original', () async { + // Before eraser: drawingA + // After eraser: splitA1, splitA2 (drawingA deleted) + // Undo: restore drawingA (needs re-add), delete splitA1, splitA2 + final pathA = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(35.5, 139.5)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final pathA1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final pathA2 = DrawingPath( + points: [LatLng(35.5, 139.5)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + + final drawingA = DrawingData( + id: 'a', + userId: 'user-123', + mapId: null, + path: pathA, + createdAt: DateTime.utc(2024, 1, 15), + ); + final splitA1 = DrawingData( + id: 'split-1', + userId: 'user-123', + mapId: null, + path: pathA1, + createdAt: DateTime.utc(2024, 1, 15), + ); + final splitA2 = DrawingData( + id: 'split-2', + userId: 'user-123', + mapId: null, + path: pathA2, + createdAt: DateTime.utc(2024, 1, 15), + ); + + final serverDrawingA = DrawingData( + id: 'server-a-new', + userId: 'user-123', + mapId: null, + path: pathA, + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('split-1')) + .thenAnswer((_) async {}); + when(() => mockRepository.deleteDrawing('split-2')) + .thenAnswer((_) async {}); + when(() => mockRepository.addDrawing(pathA)) + .thenAnswer((_) async => serverDrawingA); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [splitA1, splitA2]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: [splitA1, splitA2], + newDrawings: [drawingA], + isAuthenticated: true, + ); + + // Split drawings should be deleted + verify(() => mockRepository.deleteDrawing('split-1')).called(1); + verify(() => mockRepository.deleteDrawing('split-2')).called(1); + // Original should be re-added (it was deleted during eraser) + verify(() => mockRepository.addDrawing(pathA)).called(1); + expect(result.length, 1); + expect(result[0].id, 'server-a-new'); + }); + + test('eraser with newDrawings: preserves unchanged, adds splits, deletes original', () async { + final pathA = DrawingPath( + points: [LatLng(35.0, 139.0), LatLng(35.5, 139.5)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + final pathB = DrawingPath( + points: [LatLng(36.0, 140.0)], + color: const Color(0xFF00FF00), + strokeWidth: 3.0, + ); + final pathA1 = DrawingPath( + points: [LatLng(35.0, 139.0)], + color: const Color(0xFFFF0000), + strokeWidth: 3.0, + ); + + final drawingA = DrawingData( + id: 'a', + userId: 'user-123', + mapId: null, + path: pathA, + createdAt: DateTime.utc(2024, 1, 15), + ); + final drawingB = DrawingData( + id: 'b', + userId: 'user-123', + mapId: null, + path: pathB, + createdAt: DateTime.utc(2024, 1, 15), + ); + // Split drawing has local ID (new) + final splitA1 = DrawingData( + id: 'local-split-1', + userId: null, + mapId: null, + path: pathA1, + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ); + + final serverSplitA1 = DrawingData( + id: 'server-split-1', + userId: 'user-123', + mapId: null, + path: pathA1, + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline).thenAnswer((_) async => true); + when(() => mockRepository.deleteDrawing('a')).thenAnswer((_) async {}); + when(() => mockRepository.addDrawing(pathA1)) + .thenAnswer((_) async => serverSplitA1); + when(() => mockStorage.getCachedDrawings()) + .thenAnswer((_) async => [drawingA, drawingB]); + when(() => mockStorage.setCachedDrawings(any())) + .thenAnswer((_) async {}); + + final result = await service.replaceDrawings( + oldDrawings: [drawingA, drawingB], + newDrawings: [splitA1, drawingB], // B unchanged (same object) + isAuthenticated: true, + ); + + // A should be deleted (not in new) + verify(() => mockRepository.deleteDrawing('a')).called(1); + // B should be preserved (same ID) + verifyNever(() => mockRepository.deleteDrawing('b')); + verifyNever(() => mockRepository.addDrawing(pathB)); + // Split should be added (local/new) + verify(() => mockRepository.addDrawing(pathA1)).called(1); + + expect(result.length, 2); + }); }); group('clearIfUserChanged', () { From 0c7494c86f70fc17cb4b81bdfe68c96c302e5436 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Mon, 16 Mar 2026 16:33:07 +0900 Subject: [PATCH 4/5] feat: multi maps --- backend/src/db/schema.ts | 37 +- backend/src/index.ts | 263 ++++++++- backend/src/schemas/map.ts | 21 + backend/src/schemas/pin.ts | 3 + doc | 516 ++++++++++++++++++ lib/api/api_client.dart | 4 + lib/api/clients/maps_client.dart | 42 ++ lib/api/clients/maps_client.g.dart | 162 ++++++ lib/api/export.dart | 6 + lib/api/models/api_maps_id_request_body.dart | 22 + .../models/api_maps_id_request_body.g.dart | 21 + lib/api/models/api_maps_request_body.dart | 22 + lib/api/models/api_maps_request_body.g.dart | 19 + lib/api/models/api_pins_request_body.dart | 2 + lib/api/models/api_pins_request_body.g.dart | 2 + lib/api/models/get_api_maps_response.dart | 28 + lib/api/models/get_api_maps_response.g.dart | 25 + lib/api/models/get_api_pins_response.dart | 2 + lib/api/models/get_api_pins_response.g.dart | 2 + lib/api/models/pins.dart | 2 + lib/api/models/pins.g.dart | 2 + lib/api/models/post_api_maps_response.dart | 28 + lib/api/models/post_api_maps_response.g.dart | 26 + .../models/post_api_pins_batch_response.dart | 2 + .../post_api_pins_batch_response.g.dart | 2 + lib/api/models/post_api_pins_response.dart | 2 + lib/api/models/post_api_pins_response.g.dart | 2 + lib/api/models/put_api_maps_id_response.dart | 28 + .../models/put_api_maps_id_response.g.dart | 27 + lib/features/map/data/drawing_repository.dart | 8 +- .../map/data/drawing_repository_base.dart | 2 +- lib/features/map/data/local_map_storage.dart | 124 +++++ lib/features/map/data/map_repository.dart | 214 ++++++++ lib/features/map/data/pin_repository.dart | 18 +- .../map/data/pin_repository_base.dart | 4 +- .../map/presentation/map_list_screen.dart | 331 +++++++++++ lib/features/map/presentation/map_screen.dart | 53 +- .../map/presentation/widgets/controls.dart | 6 +- .../map/providers/current_map_provider.dart | 147 +++++ .../map/providers/drawing_provider.dart | 88 ++- lib/features/map/providers/map_provider.dart | 146 +++++ lib/features/map/providers/pin_provider.dart | 38 +- .../map/services/drawing_sync_service.dart | 38 +- .../map/services/map_sync_service.dart | 194 +++++++ .../map/services/pin_sync_service.dart | 35 +- lib/router/app_router.dart | 5 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/features/map/mocks/mocks.dart | 6 + .../providers/current_map_provider_test.dart | 276 ++++++++++ .../map/providers/drawing_notifier_test.dart | 31 +- .../services/drawing_sync_service_test.dart | 47 ++ .../map/services/map_sync_service_test.dart | 403 ++++++++++++++ .../map/services/pin_sync_service_test.dart | 46 ++ web/index.html | 14 +- 55 files changed, 3507 insertions(+), 90 deletions(-) create mode 100644 backend/src/schemas/map.ts create mode 100644 lib/api/clients/maps_client.dart create mode 100644 lib/api/clients/maps_client.g.dart create mode 100644 lib/api/models/api_maps_id_request_body.dart create mode 100644 lib/api/models/api_maps_id_request_body.g.dart create mode 100644 lib/api/models/api_maps_request_body.dart create mode 100644 lib/api/models/api_maps_request_body.g.dart create mode 100644 lib/api/models/get_api_maps_response.dart create mode 100644 lib/api/models/get_api_maps_response.g.dart create mode 100644 lib/api/models/post_api_maps_response.dart create mode 100644 lib/api/models/post_api_maps_response.g.dart create mode 100644 lib/api/models/put_api_maps_id_response.dart create mode 100644 lib/api/models/put_api_maps_id_response.g.dart create mode 100644 lib/features/map/data/local_map_storage.dart create mode 100644 lib/features/map/data/map_repository.dart create mode 100644 lib/features/map/presentation/map_list_screen.dart create mode 100644 lib/features/map/providers/current_map_provider.dart create mode 100644 lib/features/map/providers/map_provider.dart create mode 100644 lib/features/map/services/map_sync_service.dart create mode 100644 test/features/map/providers/current_map_provider_test.dart create mode 100644 test/features/map/services/map_sync_service_test.dart diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ebed9ff..5aee809 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,3 +1,4 @@ +import { relations } from "drizzle-orm"; import { boolean, doublePrecision, @@ -68,17 +69,44 @@ export const jwks = pgTable("jwks", { }); // Application tables + +export const maps = pgTable("maps", { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export type Map = typeof maps.$inferSelect; +export type NewMap = typeof maps.$inferInsert; + +export const mapsRelations = relations(maps, ({ many }) => ({ + pins: many(pins), + drawings: many(drawings), +})); + // userId is text to match Better Auth user.id export const pins = pgTable("pins", { id: uuid("id").primaryKey().defaultRandom(), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), + mapId: uuid("map_id").references(() => maps.id, { onDelete: "cascade" }), latitude: doublePrecision("latitude").notNull(), longitude: doublePrecision("longitude").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }); +export const pinsRelations = relations(pins, ({ one }) => ({ + map: one(maps, { + fields: [pins.mapId], + references: [maps.id], + }), +})); + export type Pin = typeof pins.$inferSelect; export type NewPin = typeof pins.$inferInsert; @@ -87,12 +115,19 @@ export const drawings = pgTable("drawings", { userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), - mapId: uuid("map_id"), + mapId: uuid("map_id").references(() => maps.id, { onDelete: "cascade" }), points: jsonb("points").notNull(), color: text("color").notNull(), strokeWidth: doublePrecision("stroke_width").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }); +export const drawingsRelations = relations(drawings, ({ one }) => ({ + map: one(maps, { + fields: [drawings.mapId], + references: [maps.id], + }), +})); + export type Drawing = typeof drawings.$inferSelect; export type NewDrawing = typeof drawings.$inferInsert; diff --git a/backend/src/index.ts b/backend/src/index.ts index a8f767b..0ee2892 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,13 +11,19 @@ import { } from "hono-openapi"; import postgres from "postgres"; import { type AuthEnv, createAuth } from "./auth"; -import { drawings, pins } from "./db/schema"; +import { drawings, maps, pins } from "./db/schema"; import { BatchCreateDrawingsSchema, CreateDrawingSchema, DrawingSchema, DrawingsArraySchema, } from "./schemas/drawing"; +import { + CreateMapSchema, + MapSchema, + MapsArraySchema, + UpdateMapSchema, +} from "./schemas/map"; import { BatchCreatePinsSchema, CreatePinSchema, @@ -48,7 +54,7 @@ app.use( ]; return allowed.includes(origin) ? origin : allowed[0]; }, - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization"], credentials: true, maxAge: 86400, @@ -135,6 +141,22 @@ async function withDb( } } +async function validateMapOwnership( + connectionString: string, + mapId: string | null | undefined, + userId: string, +): Promise { + if (!mapId) return true; + const result = await withDb(connectionString, (db) => + db + .select({ id: maps.id }) + .from(maps) + .where(and(eq(maps.id, mapId), eq(maps.userId, userId))) + .limit(1), + ); + return result.length > 0; +} + app.get( "/api/me", describeRoute({ @@ -237,12 +259,19 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); + if ( + !(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId)) + ) { + return c.json({ error: "Map not found" }, 404); + } + try { const [data] = await withDb(c.env.DATABASE_URL, (db) => db .insert(pins) .values({ userId, + mapId: body.mapId ?? null, latitude: body.latitude, longitude: body.longitude, }) @@ -332,8 +361,16 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); + const mapIds = [...new Set(body.pins.map((p) => p.mapId).filter(Boolean))]; + for (const mapId of mapIds) { + if (!(await validateMapOwnership(c.env.DATABASE_URL, mapId, userId))) { + return c.json({ error: "Map not found" }, 404); + } + } + const pinsToInsert = body.pins.map((pin) => ({ userId, + mapId: pin.mapId ?? null, latitude: pin.latitude, longitude: pin.longitude, })); @@ -426,6 +463,12 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); + if ( + !(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId)) + ) { + return c.json({ error: "Map not found" }, 404); + } + try { const [data] = await withDb(c.env.DATABASE_URL, (db) => db @@ -527,6 +570,15 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); + const mapIds = [ + ...new Set(body.drawings.map((d) => d.mapId).filter(Boolean)), + ]; + for (const mapId of mapIds) { + if (!(await validateMapOwnership(c.env.DATABASE_URL, mapId, userId))) { + return c.json({ error: "Map not found" }, 404); + } + } + const drawingsToInsert = body.drawings.map((drawing) => ({ userId, mapId: drawing.mapId ?? null, @@ -548,4 +600,211 @@ app.post( }, ); +// Maps API + +app.get( + "/api/maps", + describeRoute({ + tags: ["maps"], + summary: "Get all maps for current user", + responses: { + 200: { + description: "List of maps", + content: { "application/json": { schema: resolver(MapsArraySchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + async (c) => { + const userId = c.get("userId"); + + try { + const data = await withDb(c.env.DATABASE_URL, (db) => + db + .select() + .from(maps) + .where(eq(maps.userId, userId)) + .orderBy(desc(maps.createdAt)), + ); + + return c.json(data); + } catch (error) { + console.error("Failed to get maps:", error); + return c.json({ error: "Failed to get maps" }, 500); + } + }, +); + +app.post( + "/api/maps", + describeRoute({ + tags: ["maps"], + summary: "Create a new map", + responses: { + 201: { + description: "Map created", + content: { "application/json": { schema: resolver(MapSchema) } }, + }, + 400: { + description: "Invalid request", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + validator("json", CreateMapSchema), + async (c) => { + const userId = c.get("userId"); + const body = c.req.valid("json"); + + try { + const [data] = await withDb(c.env.DATABASE_URL, (db) => + db + .insert(maps) + .values({ + userId, + name: body.name, + description: body.description ?? null, + }) + .returning(), + ); + + return c.json(data, 201); + } catch (error) { + console.error("Failed to create map:", error); + return c.json({ error: "Failed to create map" }, 500); + } + }, +); + +app.put( + "/api/maps/:id", + describeRoute({ + tags: ["maps"], + summary: "Update a map", + responses: { + 200: { + description: "Map updated", + content: { "application/json": { schema: resolver(MapSchema) } }, + }, + 400: { + description: "Invalid request", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 404: { + description: "Map not found", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + validator("json", UpdateMapSchema), + async (c) => { + const userId = c.get("userId"); + const mapId = c.req.param("id"); + const body = c.req.valid("json"); + + if (!mapId || !/^[0-9a-f-]{36}$/i.test(mapId)) { + return c.json({ error: "Invalid map ID" }, 400); + } + + try { + const updateData: { name?: string; description?: string | null } = {}; + if (body.name !== undefined) updateData.name = body.name; + if (body.description !== undefined) + updateData.description = body.description; + + if (Object.keys(updateData).length === 0) { + return c.json({ error: "No fields to update" }, 400); + } + + const [data] = await withDb(c.env.DATABASE_URL, (db) => + db + .update(maps) + .set(updateData) + .where(and(eq(maps.id, mapId), eq(maps.userId, userId))) + .returning(), + ); + + if (!data) { + return c.json({ error: "Map not found" }, 404); + } + + return c.json(data); + } catch (error) { + console.error("Failed to update map:", error); + return c.json({ error: "Failed to update map" }, 500); + } + }, +); + +app.delete( + "/api/maps/:id", + describeRoute({ + tags: ["maps"], + summary: "Delete a map (and all associated pins/drawings)", + responses: { + 204: { + description: "Map deleted", + }, + 400: { + description: "Invalid map ID", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + async (c) => { + const userId = c.get("userId"); + const mapId = c.req.param("id"); + + if (!mapId || !/^[0-9a-f-]{36}$/i.test(mapId)) { + return c.json({ error: "Invalid map ID" }, 400); + } + + try { + await withDb(c.env.DATABASE_URL, (db) => + db.delete(maps).where(and(eq(maps.id, mapId), eq(maps.userId, userId))), + ); + + return c.body(null, 204); + } catch (error) { + console.error("Failed to delete map:", error); + return c.json({ error: "Failed to delete map" }, 500); + } + }, +); + export default app; diff --git a/backend/src/schemas/map.ts b/backend/src/schemas/map.ts new file mode 100644 index 0000000..8a7472f --- /dev/null +++ b/backend/src/schemas/map.ts @@ -0,0 +1,21 @@ +import * as v from "valibot"; + +export const CreateMapSchema = v.object({ + name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), + description: v.optional(v.nullable(v.pipe(v.string(), v.maxLength(500)))), +}); + +export const UpdateMapSchema = v.object({ + name: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(100))), + description: v.optional(v.nullable(v.pipe(v.string(), v.maxLength(500)))), +}); + +export const MapSchema = v.object({ + id: v.string(), + userId: v.string(), + name: v.string(), + description: v.nullable(v.string()), + createdAt: v.string(), +}); + +export const MapsArraySchema = v.array(MapSchema); diff --git a/backend/src/schemas/pin.ts b/backend/src/schemas/pin.ts index 4573640..70a80bf 100644 --- a/backend/src/schemas/pin.ts +++ b/backend/src/schemas/pin.ts @@ -15,6 +15,7 @@ export const LongitudeSchema = v.pipe( export const CreatePinSchema = v.object({ latitude: LatitudeSchema, longitude: LongitudeSchema, + mapId: v.optional(v.nullable(v.string())), }); export const BatchCreatePinsSchema = v.object({ @@ -23,6 +24,7 @@ export const BatchCreatePinsSchema = v.object({ v.object({ latitude: LatitudeSchema, longitude: LongitudeSchema, + mapId: v.optional(v.nullable(v.string())), }), ), v.maxLength(100), @@ -32,6 +34,7 @@ export const BatchCreatePinsSchema = v.object({ export const PinSchema = v.object({ id: v.string(), userId: v.string(), + mapId: v.nullable(v.string()), latitude: v.number(), longitude: v.number(), createdAt: v.string(), diff --git a/doc b/doc index 6c3255a..a9f0b01 100644 --- a/doc +++ b/doc @@ -119,6 +119,16 @@ "userId": { "type": "string" }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "latitude": { "type": "number" }, @@ -132,6 +142,7 @@ "required": [ "id", "userId", + "mapId", "latitude", "longitude", "createdAt" @@ -199,6 +210,16 @@ "userId": { "type": "string" }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "latitude": { "type": "number" }, @@ -212,6 +233,7 @@ "required": [ "id", "userId", + "mapId", "latitude", "longitude", "createdAt" @@ -290,6 +312,16 @@ "type": "number", "minimum": -180, "maximum": 180 + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -403,6 +435,16 @@ "userId": { "type": "string" }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "latitude": { "type": "number" }, @@ -416,6 +458,7 @@ "required": [ "id", "userId", + "mapId", "latitude", "longitude", "createdAt" @@ -500,6 +543,16 @@ "type": "number", "minimum": -180, "maximum": 180 + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1085,6 +1138,469 @@ } } } + }, + "/api/maps": { + "get": { + "operationId": "getApiMaps", + "tags": [ + "maps" + ], + "summary": "Get all maps for current user", + "responses": { + "200": { + "description": "List of maps", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "name", + "description", + "createdAt" + ] + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + }, + "post": { + "operationId": "postApiMaps", + "tags": [ + "maps" + ], + "summary": "Create a new map", + "responses": { + "201": { + "description": "Map created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "name", + "description", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ] + } + } + } + } + } + }, + "/api/maps/{id}": { + "put": { + "operationId": "putApiMapsById", + "tags": [ + "maps" + ], + "summary": "Update a map", + "responses": { + "200": { + "description": "Map updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "name", + "description", + "createdAt" + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Map not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": [] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ] + }, + "delete": { + "operationId": "deleteApiMapsById", + "tags": [ + "maps" + ], + "summary": "Delete a map (and all associated pins/drawings)", + "responses": { + "204": { + "description": "Map deleted" + }, + "400": { + "description": "Invalid map ID", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ] + } } }, "components": {} diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 83f56e4..cd9eb63 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -8,6 +8,7 @@ import 'clients/system_client.dart'; import 'clients/user_client.dart'; import 'clients/pins_client.dart'; import 'clients/drawings_client.dart'; +import 'clients/maps_client.dart'; /// Memomap API `v1.0.0`. /// @@ -28,6 +29,7 @@ class ApiClient { UserClient? _user; PinsClient? _pins; DrawingsClient? _drawings; + MapsClient? _maps; SystemClient get system => _system ??= SystemClient(_dio, baseUrl: _baseUrl); @@ -36,4 +38,6 @@ class ApiClient { PinsClient get pins => _pins ??= PinsClient(_dio, baseUrl: _baseUrl); DrawingsClient get drawings => _drawings ??= DrawingsClient(_dio, baseUrl: _baseUrl); + + MapsClient get maps => _maps ??= MapsClient(_dio, baseUrl: _baseUrl); } diff --git a/lib/api/clients/maps_client.dart b/lib/api/clients/maps_client.dart new file mode 100644 index 0000000..521bc41 --- /dev/null +++ b/lib/api/clients/maps_client.dart @@ -0,0 +1,42 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../models/api_maps_id_request_body.dart'; +import '../models/api_maps_request_body.dart'; +import '../models/get_api_maps_response.dart'; +import '../models/post_api_maps_response.dart'; +import '../models/put_api_maps_id_response.dart'; + +part 'maps_client.g.dart'; + +@RestApi() +abstract class MapsClient { + factory MapsClient(Dio dio, {String? baseUrl}) = _MapsClient; + + /// Get all maps for current user + @GET('/api/maps') + Future> getApiMaps(); + + /// Create a new map + @POST('/api/maps') + Future postApiMaps({ + @Body() ApiMapsRequestBody? body, + }); + + /// Update a map + @PUT('/api/maps/{id}') + Future putApiMapsById({ + @Path('id') required String id, + @Body() ApiMapsIdRequestBody? body, + }); + + /// Delete a map (and all associated pins/drawings) + @DELETE('/api/maps/{id}') + Future deleteApiMapsById({ + @Path('id') required String id, + }); +} diff --git a/lib/api/clients/maps_client.g.dart b/lib/api/clients/maps_client.g.dart new file mode 100644 index 0000000..9ba1df3 --- /dev/null +++ b/lib/api/clients/maps_client.g.dart @@ -0,0 +1,162 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'maps_client.dart'; + +// dart format off + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter,avoid_unused_constructor_parameters,unreachable_from_main + +class _MapsClient implements MapsClient { + _MapsClient(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getApiMaps() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/maps', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late List _value; + try { + _value = _result.data! + .map( + (dynamic i) => + GetApiMapsResponse.fromJson(i as Map), + ) + .toList(); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + @override + Future postApiMaps({ApiMapsRequestBody? body}) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = {}; + _data.addAll(body?.toJson() ?? {}); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/maps', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late PostApiMapsResponse _value; + try { + _value = PostApiMapsResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + @override + Future putApiMapsById({ + required String id, + ApiMapsIdRequestBody? body, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = {}; + _data.addAll(body?.toJson() ?? {}); + final _options = _setStreamType( + Options(method: 'PUT', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/maps/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late PutApiMapsIdResponse _value; + try { + _value = PutApiMapsIdResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + + @override + Future deleteApiMapsById({required String id}) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType( + Options(method: 'DELETE', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/maps/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + await _dio.fetch(_options); + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} + +// dart format on diff --git a/lib/api/export.dart b/lib/api/export.dart index e0e3caa..84a455e 100644 --- a/lib/api/export.dart +++ b/lib/api/export.dart @@ -7,6 +7,7 @@ export 'clients/system_client.dart'; export 'clients/user_client.dart'; export 'clients/pins_client.dart'; export 'clients/drawings_client.dart'; +export 'clients/maps_client.dart'; // Data classes export 'models/get_health_response.dart'; export 'models/get_api_me_response.dart'; @@ -27,6 +28,11 @@ export 'models/post_api_drawings_batch_response.dart'; export 'models/points5.dart'; export 'models/drawings.dart'; export 'models/api_drawings_batch_request_body.dart'; +export 'models/get_api_maps_response.dart'; +export 'models/post_api_maps_response.dart'; +export 'models/api_maps_request_body.dart'; +export 'models/put_api_maps_id_response.dart'; +export 'models/api_maps_id_request_body.dart'; // Root client export 'api_client.dart'; diff --git a/lib/api/models/api_maps_id_request_body.dart b/lib/api/models/api_maps_id_request_body.dart new file mode 100644 index 0000000..b7ebd5a --- /dev/null +++ b/lib/api/models/api_maps_id_request_body.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'api_maps_id_request_body.g.dart'; + +@JsonSerializable() +class ApiMapsIdRequestBody { + const ApiMapsIdRequestBody({ + this.name, + this.description, + }); + + factory ApiMapsIdRequestBody.fromJson(Map json) => _$ApiMapsIdRequestBodyFromJson(json); + + final String? name; + final String? description; + + Map toJson() => _$ApiMapsIdRequestBodyToJson(this); +} diff --git a/lib/api/models/api_maps_id_request_body.g.dart b/lib/api/models/api_maps_id_request_body.g.dart new file mode 100644 index 0000000..1aea86c --- /dev/null +++ b/lib/api/models/api_maps_id_request_body.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_maps_id_request_body.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiMapsIdRequestBody _$ApiMapsIdRequestBodyFromJson( + Map json, +) => ApiMapsIdRequestBody( + name: json['name'] as String?, + description: json['description'] as String?, +); + +Map _$ApiMapsIdRequestBodyToJson( + ApiMapsIdRequestBody instance, +) => { + 'name': instance.name, + 'description': instance.description, +}; diff --git a/lib/api/models/api_maps_request_body.dart b/lib/api/models/api_maps_request_body.dart new file mode 100644 index 0000000..1f8704d --- /dev/null +++ b/lib/api/models/api_maps_request_body.dart @@ -0,0 +1,22 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'api_maps_request_body.g.dart'; + +@JsonSerializable() +class ApiMapsRequestBody { + const ApiMapsRequestBody({ + required this.name, + this.description, + }); + + factory ApiMapsRequestBody.fromJson(Map json) => _$ApiMapsRequestBodyFromJson(json); + + final String name; + final String? description; + + Map toJson() => _$ApiMapsRequestBodyToJson(this); +} diff --git a/lib/api/models/api_maps_request_body.g.dart b/lib/api/models/api_maps_request_body.g.dart new file mode 100644 index 0000000..e319c40 --- /dev/null +++ b/lib/api/models/api_maps_request_body.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_maps_request_body.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiMapsRequestBody _$ApiMapsRequestBodyFromJson(Map json) => + ApiMapsRequestBody( + name: json['name'] as String, + description: json['description'] as String?, + ); + +Map _$ApiMapsRequestBodyToJson(ApiMapsRequestBody instance) => + { + 'name': instance.name, + 'description': instance.description, + }; diff --git a/lib/api/models/api_pins_request_body.dart b/lib/api/models/api_pins_request_body.dart index 14e1a10..b8fcdc9 100644 --- a/lib/api/models/api_pins_request_body.dart +++ b/lib/api/models/api_pins_request_body.dart @@ -11,12 +11,14 @@ class ApiPinsRequestBody { const ApiPinsRequestBody({ required this.latitude, required this.longitude, + this.mapId, }); factory ApiPinsRequestBody.fromJson(Map json) => _$ApiPinsRequestBodyFromJson(json); final num latitude; final num longitude; + final String? mapId; Map toJson() => _$ApiPinsRequestBodyToJson(this); } diff --git a/lib/api/models/api_pins_request_body.g.dart b/lib/api/models/api_pins_request_body.g.dart index 1146157..b83b501 100644 --- a/lib/api/models/api_pins_request_body.g.dart +++ b/lib/api/models/api_pins_request_body.g.dart @@ -10,10 +10,12 @@ ApiPinsRequestBody _$ApiPinsRequestBodyFromJson(Map json) => ApiPinsRequestBody( latitude: json['latitude'] as num, longitude: json['longitude'] as num, + mapId: json['mapId'] as String?, ); Map _$ApiPinsRequestBodyToJson(ApiPinsRequestBody instance) => { 'latitude': instance.latitude, 'longitude': instance.longitude, + 'mapId': instance.mapId, }; diff --git a/lib/api/models/get_api_maps_response.dart b/lib/api/models/get_api_maps_response.dart new file mode 100644 index 0000000..13a95b7 --- /dev/null +++ b/lib/api/models/get_api_maps_response.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'get_api_maps_response.g.dart'; + +@JsonSerializable() +class GetApiMapsResponse { + const GetApiMapsResponse({ + required this.id, + required this.userId, + required this.name, + required this.description, + required this.createdAt, + }); + + factory GetApiMapsResponse.fromJson(Map json) => _$GetApiMapsResponseFromJson(json); + + final String id; + final String userId; + final String name; + final String? description; + final String createdAt; + + Map toJson() => _$GetApiMapsResponseToJson(this); +} diff --git a/lib/api/models/get_api_maps_response.g.dart b/lib/api/models/get_api_maps_response.g.dart new file mode 100644 index 0000000..895c3da --- /dev/null +++ b/lib/api/models/get_api_maps_response.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_api_maps_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetApiMapsResponse _$GetApiMapsResponseFromJson(Map json) => + GetApiMapsResponse( + id: json['id'] as String, + userId: json['userId'] as String, + name: json['name'] as String, + description: json['description'] as String?, + createdAt: json['createdAt'] as String, + ); + +Map _$GetApiMapsResponseToJson(GetApiMapsResponse instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'name': instance.name, + 'description': instance.description, + 'createdAt': instance.createdAt, + }; diff --git a/lib/api/models/get_api_pins_response.dart b/lib/api/models/get_api_pins_response.dart index 1f2a482..2351023 100644 --- a/lib/api/models/get_api_pins_response.dart +++ b/lib/api/models/get_api_pins_response.dart @@ -11,6 +11,7 @@ class GetApiPinsResponse { const GetApiPinsResponse({ required this.id, required this.userId, + required this.mapId, required this.latitude, required this.longitude, required this.createdAt, @@ -20,6 +21,7 @@ class GetApiPinsResponse { final String id; final String userId; + final String? mapId; final num latitude; final num longitude; final String createdAt; diff --git a/lib/api/models/get_api_pins_response.g.dart b/lib/api/models/get_api_pins_response.g.dart index 2c739fe..dcb70cb 100644 --- a/lib/api/models/get_api_pins_response.g.dart +++ b/lib/api/models/get_api_pins_response.g.dart @@ -10,6 +10,7 @@ GetApiPinsResponse _$GetApiPinsResponseFromJson(Map json) => GetApiPinsResponse( id: json['id'] as String, userId: json['userId'] as String, + mapId: json['mapId'] as String?, latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, @@ -19,6 +20,7 @@ Map _$GetApiPinsResponseToJson(GetApiPinsResponse instance) => { 'id': instance.id, 'userId': instance.userId, + 'mapId': instance.mapId, 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, diff --git a/lib/api/models/pins.dart b/lib/api/models/pins.dart index f9cae15..9dd2162 100644 --- a/lib/api/models/pins.dart +++ b/lib/api/models/pins.dart @@ -11,12 +11,14 @@ class Pins { const Pins({ required this.latitude, required this.longitude, + this.mapId, }); factory Pins.fromJson(Map json) => _$PinsFromJson(json); final num latitude; final num longitude; + final String? mapId; Map toJson() => _$PinsToJson(this); } diff --git a/lib/api/models/pins.g.dart b/lib/api/models/pins.g.dart index 31a15d7..d583ddd 100644 --- a/lib/api/models/pins.g.dart +++ b/lib/api/models/pins.g.dart @@ -9,9 +9,11 @@ part of 'pins.dart'; Pins _$PinsFromJson(Map json) => Pins( latitude: json['latitude'] as num, longitude: json['longitude'] as num, + mapId: json['mapId'] as String?, ); Map _$PinsToJson(Pins instance) => { 'latitude': instance.latitude, 'longitude': instance.longitude, + 'mapId': instance.mapId, }; diff --git a/lib/api/models/post_api_maps_response.dart b/lib/api/models/post_api_maps_response.dart new file mode 100644 index 0000000..ee9a5c0 --- /dev/null +++ b/lib/api/models/post_api_maps_response.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'post_api_maps_response.g.dart'; + +@JsonSerializable() +class PostApiMapsResponse { + const PostApiMapsResponse({ + required this.id, + required this.userId, + required this.name, + required this.description, + required this.createdAt, + }); + + factory PostApiMapsResponse.fromJson(Map json) => _$PostApiMapsResponseFromJson(json); + + final String id; + final String userId; + final String name; + final String? description; + final String createdAt; + + Map toJson() => _$PostApiMapsResponseToJson(this); +} diff --git a/lib/api/models/post_api_maps_response.g.dart b/lib/api/models/post_api_maps_response.g.dart new file mode 100644 index 0000000..88944c3 --- /dev/null +++ b/lib/api/models/post_api_maps_response.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_api_maps_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PostApiMapsResponse _$PostApiMapsResponseFromJson(Map json) => + PostApiMapsResponse( + id: json['id'] as String, + userId: json['userId'] as String, + name: json['name'] as String, + description: json['description'] as String?, + createdAt: json['createdAt'] as String, + ); + +Map _$PostApiMapsResponseToJson( + PostApiMapsResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'name': instance.name, + 'description': instance.description, + 'createdAt': instance.createdAt, +}; diff --git a/lib/api/models/post_api_pins_batch_response.dart b/lib/api/models/post_api_pins_batch_response.dart index 7449525..c83fa95 100644 --- a/lib/api/models/post_api_pins_batch_response.dart +++ b/lib/api/models/post_api_pins_batch_response.dart @@ -11,6 +11,7 @@ class PostApiPinsBatchResponse { const PostApiPinsBatchResponse({ required this.id, required this.userId, + required this.mapId, required this.latitude, required this.longitude, required this.createdAt, @@ -20,6 +21,7 @@ class PostApiPinsBatchResponse { final String id; final String userId; + final String? mapId; final num latitude; final num longitude; final String createdAt; diff --git a/lib/api/models/post_api_pins_batch_response.g.dart b/lib/api/models/post_api_pins_batch_response.g.dart index 2dee933..187ec2f 100644 --- a/lib/api/models/post_api_pins_batch_response.g.dart +++ b/lib/api/models/post_api_pins_batch_response.g.dart @@ -11,6 +11,7 @@ PostApiPinsBatchResponse _$PostApiPinsBatchResponseFromJson( ) => PostApiPinsBatchResponse( id: json['id'] as String, userId: json['userId'] as String, + mapId: json['mapId'] as String?, latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, @@ -21,6 +22,7 @@ Map _$PostApiPinsBatchResponseToJson( ) => { 'id': instance.id, 'userId': instance.userId, + 'mapId': instance.mapId, 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, diff --git a/lib/api/models/post_api_pins_response.dart b/lib/api/models/post_api_pins_response.dart index 79d6ada..39cd32d 100644 --- a/lib/api/models/post_api_pins_response.dart +++ b/lib/api/models/post_api_pins_response.dart @@ -11,6 +11,7 @@ class PostApiPinsResponse { const PostApiPinsResponse({ required this.id, required this.userId, + required this.mapId, required this.latitude, required this.longitude, required this.createdAt, @@ -20,6 +21,7 @@ class PostApiPinsResponse { final String id; final String userId; + final String? mapId; final num latitude; final num longitude; final String createdAt; diff --git a/lib/api/models/post_api_pins_response.g.dart b/lib/api/models/post_api_pins_response.g.dart index 296cf70..0b3f875 100644 --- a/lib/api/models/post_api_pins_response.g.dart +++ b/lib/api/models/post_api_pins_response.g.dart @@ -10,6 +10,7 @@ PostApiPinsResponse _$PostApiPinsResponseFromJson(Map json) => PostApiPinsResponse( id: json['id'] as String, userId: json['userId'] as String, + mapId: json['mapId'] as String?, latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, @@ -20,6 +21,7 @@ Map _$PostApiPinsResponseToJson( ) => { 'id': instance.id, 'userId': instance.userId, + 'mapId': instance.mapId, 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, diff --git a/lib/api/models/put_api_maps_id_response.dart b/lib/api/models/put_api_maps_id_response.dart new file mode 100644 index 0000000..be74c6d --- /dev/null +++ b/lib/api/models/put_api_maps_id_response.dart @@ -0,0 +1,28 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_import, invalid_annotation_target, unnecessary_import + +import 'package:json_annotation/json_annotation.dart'; + +part 'put_api_maps_id_response.g.dart'; + +@JsonSerializable() +class PutApiMapsIdResponse { + const PutApiMapsIdResponse({ + required this.id, + required this.userId, + required this.name, + required this.description, + required this.createdAt, + }); + + factory PutApiMapsIdResponse.fromJson(Map json) => _$PutApiMapsIdResponseFromJson(json); + + final String id; + final String userId; + final String name; + final String? description; + final String createdAt; + + Map toJson() => _$PutApiMapsIdResponseToJson(this); +} diff --git a/lib/api/models/put_api_maps_id_response.g.dart b/lib/api/models/put_api_maps_id_response.g.dart new file mode 100644 index 0000000..960537a --- /dev/null +++ b/lib/api/models/put_api_maps_id_response.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'put_api_maps_id_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PutApiMapsIdResponse _$PutApiMapsIdResponseFromJson( + Map json, +) => PutApiMapsIdResponse( + id: json['id'] as String, + userId: json['userId'] as String, + name: json['name'] as String, + description: json['description'] as String?, + createdAt: json['createdAt'] as String, +); + +Map _$PutApiMapsIdResponseToJson( + PutApiMapsIdResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'name': instance.name, + 'description': instance.description, + 'createdAt': instance.createdAt, +}; diff --git a/lib/features/map/data/drawing_repository.dart b/lib/features/map/data/drawing_repository.dart index 11ef0e3..f5e0f30 100644 --- a/lib/features/map/data/drawing_repository.dart +++ b/lib/features/map/data/drawing_repository.dart @@ -103,11 +103,11 @@ class DrawingData { this.isLocal = false, }); - factory DrawingData.local(DrawingPath path) { + factory DrawingData.local(DrawingPath path, {String? mapId}) { return DrawingData( id: const Uuid().v4(), userId: null, - mapId: null, + mapId: mapId, path: path, createdAt: DateTime.now(), isLocal: true, @@ -165,7 +165,7 @@ class DrawingRepository implements DrawingRepositoryBase { } @override - Future addDrawing(DrawingPath path) async { + Future addDrawing(DrawingPath path, {String? mapId}) async { if (!await _isAuthenticated()) return null; final response = await _api.drawings.postApiDrawings( @@ -175,6 +175,7 @@ class DrawingRepository implements DrawingRepositoryBase { .toList(), color: path.color.toARGB32().toString(), strokeWidth: path.strokeWidth, + mapId: mapId, ), ); @@ -203,6 +204,7 @@ class DrawingRepository implements DrawingRepositoryBase { .toList(), color: drawing.path.color.toARGB32().toString(), strokeWidth: drawing.path.strokeWidth, + mapId: drawing.mapId, )) .toList(), ), diff --git a/lib/features/map/data/drawing_repository_base.dart b/lib/features/map/data/drawing_repository_base.dart index 229af79..5145fd8 100644 --- a/lib/features/map/data/drawing_repository_base.dart +++ b/lib/features/map/data/drawing_repository_base.dart @@ -3,7 +3,7 @@ import 'package:memomap/features/map/models/drawing_path.dart'; abstract interface class DrawingRepositoryBase { Future> getDrawings(); - Future addDrawing(DrawingPath path); + Future addDrawing(DrawingPath path, {String? mapId}); Future deleteDrawing(String id); Future> uploadLocalDrawings(List localDrawings); } diff --git a/lib/features/map/data/local_map_storage.dart b/lib/features/map/data/local_map_storage.dart new file mode 100644 index 0000000..97bef57 --- /dev/null +++ b/lib/features/map/data/local_map_storage.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:memomap/features/map/data/map_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class LocalMapStorageBase { + Future> getCachedMaps(); + Future setCachedMaps(List maps); + + Future> getLocalMaps(); + Future setLocalMaps(List maps); + + Future> getPendingDeletions(); + Future setPendingDeletions(List ids); + + Future getCurrentMapId(); + Future setCurrentMapId(String? mapId); + + Future getLastUserId(); + Future setLastUserId(String? userId); + + Future clearAll(); +} + +class SharedPreferencesLocalMapStorage implements LocalMapStorageBase { + static const _cachedMapsKey = 'memomap_cached_maps'; + static const _localMapsKey = 'memomap_local_maps'; + static const _pendingDeletionsKey = 'memomap_map_pending_deletions'; + static const _currentMapIdKey = 'memomap_current_map_id'; + static const _lastUserIdKey = 'memomap_map_last_user_id'; + + final SharedPreferencesAsync _prefs; + + SharedPreferencesLocalMapStorage(this._prefs); + + @override + Future> getCachedMaps() async { + final jsonString = await _prefs.getString(_cachedMapsKey); + if (jsonString == null) return []; + return _decodeMapList(jsonString); + } + + @override + Future setCachedMaps(List maps) async { + final jsonString = _encodeMapList(maps); + await _prefs.setString(_cachedMapsKey, jsonString); + } + + @override + Future> getLocalMaps() async { + final jsonString = await _prefs.getString(_localMapsKey); + if (jsonString == null) return []; + return _decodeMapList(jsonString); + } + + @override + Future setLocalMaps(List maps) async { + final jsonString = _encodeMapList(maps); + await _prefs.setString(_localMapsKey, jsonString); + } + + @override + Future> getPendingDeletions() async { + final jsonString = await _prefs.getString(_pendingDeletionsKey); + if (jsonString == null) return []; + final list = jsonDecode(jsonString) as List; + return list.cast(); + } + + @override + Future setPendingDeletions(List ids) async { + final jsonString = jsonEncode(ids); + await _prefs.setString(_pendingDeletionsKey, jsonString); + } + + @override + Future getCurrentMapId() async { + return _prefs.getString(_currentMapIdKey); + } + + @override + Future setCurrentMapId(String? mapId) async { + if (mapId == null) { + await _prefs.remove(_currentMapIdKey); + } else { + await _prefs.setString(_currentMapIdKey, mapId); + } + } + + @override + Future getLastUserId() async { + return _prefs.getString(_lastUserIdKey); + } + + @override + Future setLastUserId(String? userId) async { + if (userId == null) { + await _prefs.remove(_lastUserIdKey); + } else { + await _prefs.setString(_lastUserIdKey, userId); + } + } + + @override + Future clearAll() async { + await Future.wait([ + _prefs.remove(_cachedMapsKey), + _prefs.remove(_localMapsKey), + _prefs.remove(_pendingDeletionsKey), + _prefs.remove(_currentMapIdKey), + ]); + } + + String _encodeMapList(List maps) { + return jsonEncode(maps.map((m) => m.toJson()).toList()); + } + + List _decodeMapList(String jsonString) { + final list = jsonDecode(jsonString) as List; + return list + .map((e) => MapData.fromJson(e as Map)) + .toList(); + } +} diff --git a/lib/features/map/data/map_repository.dart b/lib/features/map/data/map_repository.dart new file mode 100644 index 0000000..f97a108 --- /dev/null +++ b/lib/features/map/data/map_repository.dart @@ -0,0 +1,214 @@ +import 'package:memomap/api/api_client.dart'; +import 'package:memomap/api/models/api_maps_id_request_body.dart'; +import 'package:memomap/api/models/api_maps_request_body.dart'; +import 'package:memomap/api/models/get_api_maps_response.dart'; +import 'package:memomap/api/models/post_api_maps_response.dart'; +import 'package:memomap/api/models/put_api_maps_id_response.dart'; +import 'package:memomap/config/backend_config.dart'; +import 'package:memomap/features/auth/data/token_storage.dart'; +import 'package:uuid/uuid.dart'; + +MapData _createMapData({ + required String id, + required String userId, + required String name, + required String? description, + required String createdAt, +}) => + MapData( + id: id, + userId: userId, + name: name, + description: description, + createdAt: DateTime.parse(createdAt), + ); + +extension GetApiMapsResponseExt on GetApiMapsResponse { + MapData toMapData() => _createMapData( + id: id, + userId: userId, + name: name, + description: description, + createdAt: createdAt, + ); +} + +extension PostApiMapsResponseExt on PostApiMapsResponse { + MapData toMapData() => _createMapData( + id: id, + userId: userId, + name: name, + description: description, + createdAt: createdAt, + ); +} + +extension PutApiMapsIdResponseExt on PutApiMapsIdResponse { + MapData toMapData() => _createMapData( + id: id, + userId: userId, + name: name, + description: description, + createdAt: createdAt, + ); +} + +class MapData { + final String id; + final String? userId; + final String name; + final String? description; + final DateTime createdAt; + final bool isLocal; + + MapData({ + required this.id, + required this.userId, + required this.name, + this.description, + required this.createdAt, + this.isLocal = false, + }); + + factory MapData.local({required String name, String? description}) { + return MapData( + id: const Uuid().v4(), + userId: null, + name: name, + description: description, + createdAt: DateTime.now(), + isLocal: true, + ); + } + + factory MapData.fromJson(Map json) { + return MapData( + id: json['id'] as String, + userId: json['userId'] as String?, + name: json['name'] as String, + description: json['description'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + isLocal: json['isLocal'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'name': name, + 'description': description, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'isLocal': isLocal, + }; + } + + MapData copyWith({ + String? id, + String? userId, + String? name, + String? description, + DateTime? createdAt, + bool? isLocal, + }) { + return MapData( + id: id ?? this.id, + userId: userId ?? this.userId, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + isLocal: isLocal ?? this.isLocal, + ); + } +} + +abstract interface class MapRepositoryBase { + Future> getMaps(); + Future createMap({required String name, String? description}); + Future updateMap(String id, {String? name, String? description}); + Future deleteMap(String id); + + /// Uploads local maps to server. Returns a mapping of old local IDs to new server IDs. + Future> uploadLocalMaps(List localMaps); +} + +class MapRepository implements MapRepositoryBase { + MapRepository._internal(this._api); + + final ApiClient _api; + + static MapRepository? _instance; + + static Future getInstance() async { + if (_instance != null) return _instance!; + final api = await BackendConfig.createApiClient(); + _instance = MapRepository._internal(api); + return _instance!; + } + + Future _isAuthenticated() async { + final token = await TokenStorage.getSessionId(); + return token != null; + } + + @override + Future> getMaps() async { + if (!await _isAuthenticated()) return []; + + final response = await _api.maps.getApiMaps(); + return response.map((r) => r.toMapData()).toList(); + } + + @override + Future createMap({required String name, String? description}) async { + if (!await _isAuthenticated()) return null; + + final response = await _api.maps.postApiMaps( + body: ApiMapsRequestBody( + name: name, + description: description, + ), + ); + + return response.toMapData(); + } + + @override + Future updateMap(String id, {String? name, String? description}) async { + if (!await _isAuthenticated()) return null; + + final response = await _api.maps.putApiMapsById( + id: id, + body: ApiMapsIdRequestBody( + name: name, + description: description, + ), + ); + + return response.toMapData(); + } + + @override + Future deleteMap(String id) async { + if (!await _isAuthenticated()) return; + + await _api.maps.deleteApiMapsById(id: id); + } + + @override + Future> uploadLocalMaps(List localMaps) async { + if (!await _isAuthenticated() || localMaps.isEmpty) return {}; + + final idMapping = {}; + for (final map in localMaps) { + final created = await createMap( + name: map.name, + description: map.description, + ); + if (created != null) { + idMapping[map.id] = created.id; + } + } + return idMapping; + } +} diff --git a/lib/features/map/data/pin_repository.dart b/lib/features/map/data/pin_repository.dart index 6cdc2df..71344da 100644 --- a/lib/features/map/data/pin_repository.dart +++ b/lib/features/map/data/pin_repository.dart @@ -14,6 +14,7 @@ import 'package:uuid/uuid.dart'; PinData _createPinData({ required String id, required String userId, + required String? mapId, required num latitude, required num longitude, required String createdAt, @@ -21,6 +22,7 @@ PinData _createPinData({ PinData( id: id, userId: userId, + mapId: mapId, position: LatLng(latitude.toDouble(), longitude.toDouble()), createdAt: DateTime.parse(createdAt), ); @@ -29,6 +31,7 @@ extension GetApiPinsResponseExt on GetApiPinsResponse { PinData toPinData() => _createPinData( id: id, userId: userId, + mapId: mapId, latitude: latitude, longitude: longitude, createdAt: createdAt, @@ -39,6 +42,7 @@ extension PostApiPinsResponseExt on PostApiPinsResponse { PinData toPinData() => _createPinData( id: id, userId: userId, + mapId: mapId, latitude: latitude, longitude: longitude, createdAt: createdAt, @@ -49,6 +53,7 @@ extension PostApiPinsBatchResponseExt on PostApiPinsBatchResponse { PinData toPinData() => _createPinData( id: id, userId: userId, + mapId: mapId, latitude: latitude, longitude: longitude, createdAt: createdAt, @@ -58,6 +63,7 @@ extension PostApiPinsBatchResponseExt on PostApiPinsBatchResponse { class PinData { final String id; final String? userId; + final String? mapId; final LatLng position; final DateTime createdAt; final bool isLocal; @@ -65,15 +71,17 @@ class PinData { PinData({ required this.id, required this.userId, + this.mapId, required this.position, required this.createdAt, this.isLocal = false, }); - factory PinData.local(LatLng position) { + factory PinData.local(LatLng position, {String? mapId}) { return PinData( id: const Uuid().v4(), userId: null, + mapId: mapId, position: position, createdAt: DateTime.now(), isLocal: true, @@ -84,6 +92,7 @@ class PinData { return PinData( id: json['id'] as String, userId: json['userId'] as String?, + mapId: json['mapId'] as String?, position: LatLng( (json['latitude'] as num).toDouble(), (json['longitude'] as num).toDouble(), @@ -97,6 +106,7 @@ class PinData { return { 'id': id, 'userId': userId, + 'mapId': mapId, 'latitude': position.latitude, 'longitude': position.longitude, 'createdAt': createdAt.toUtc().toIso8601String(), @@ -133,13 +143,14 @@ class PinRepository implements PinRepositoryBase { } @override - Future addPin(LatLng position) async { + Future addPin(LatLng position, {String? mapId}) async { if (!await _isAuthenticated()) return null; final response = await _api.pins.postApiPins( body: ApiPinsRequestBody( latitude: position.latitude, longitude: position.longitude, + mapId: mapId, ), ); @@ -154,7 +165,7 @@ class PinRepository implements PinRepositoryBase { } @override - Future> uploadLocalPins(List localPins) async { + Future> uploadLocalPins(List localPins, {String? mapId}) async { if (!await _isAuthenticated() || localPins.isEmpty) return []; final response = await _api.pins.postApiPinsBatch( @@ -163,6 +174,7 @@ class PinRepository implements PinRepositoryBase { .map((pin) => Pins( latitude: pin.position.latitude, longitude: pin.position.longitude, + mapId: pin.mapId ?? mapId, )) .toList(), ), diff --git a/lib/features/map/data/pin_repository_base.dart b/lib/features/map/data/pin_repository_base.dart index 65e4339..a267e1d 100644 --- a/lib/features/map/data/pin_repository_base.dart +++ b/lib/features/map/data/pin_repository_base.dart @@ -3,7 +3,7 @@ import 'package:memomap/features/map/data/pin_repository.dart'; abstract interface class PinRepositoryBase { Future> getPins(); - Future addPin(LatLng position); + Future addPin(LatLng position, {String? mapId}); Future deletePin(String id); - Future> uploadLocalPins(List localPins); + Future> uploadLocalPins(List localPins, {String? mapId}); } diff --git a/lib/features/map/presentation/map_list_screen.dart b/lib/features/map/presentation/map_list_screen.dart new file mode 100644 index 0000000..9d05d4a --- /dev/null +++ b/lib/features/map/presentation/map_list_screen.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/map_provider.dart'; + +class MapListScreen extends ConsumerWidget { + const MapListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mapsAsync = ref.watch(mapsProvider); + final currentMapId = ref.watch(currentMapIdProvider); + + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('Maps'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: mapsAsync.when( + data: (maps) { + final isLastMap = maps.length <= 1; + + return ListView.builder( + itemCount: maps.length, + itemBuilder: (context, index) { + final map = maps[index]; + final isSelected = map.id == currentMapId; + final dateFormat = DateFormat('yyyy/MM/dd'); + + return ListTile( + leading: isSelected + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.map_outlined), + title: Text( + map.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Text( + map.description ?? dateFormat.format(map.createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (map.isLocal) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon( + Icons.cloud_off, + size: 16, + color: Colors.orange, + ), + ), + IconButton( + icon: const Icon(Icons.edit, size: 20), + onPressed: () => _showEditMapDialog(context, ref, map), + tooltip: 'Edit', + ), + IconButton( + icon: Icon(Icons.delete, size: 20, color: isLastMap ? Colors.grey : Colors.red), + onPressed: isLastMap ? null : () => _showDeleteConfirmDialog(context, ref, map), + tooltip: 'Delete', + ), + ], + ), + onTap: () { + ref.read(currentMapIdProvider.notifier).setCurrentMapId(map.id); + context.pop(); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text('Error: $error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.invalidate(mapsProvider), + child: const Text('Reload'), + ), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateMapDialog(context, ref), + tooltip: 'Create new map', + child: const Icon(Icons.add), + ), + ); + } + + void _showCreateMapDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => const _CreateMapDialog(), + ); + } + + void _showEditMapDialog(BuildContext context, WidgetRef ref, MapData map) { + showDialog( + context: context, + builder: (context) => _EditMapDialog(map: map), + ); + } + + void _showDeleteConfirmDialog(BuildContext context, WidgetRef ref, MapData map) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Map'), + content: Text('Delete "${map.name}"?\nAll pins and drawings in this map will also be deleted.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await ref.read(mapsProvider.notifier).deleteMap(map); + }, + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} + +class _CreateMapDialog extends ConsumerStatefulWidget { + const _CreateMapDialog(); + + @override + ConsumerState<_CreateMapDialog> createState() => _CreateMapDialogState(); +} + +class _CreateMapDialogState extends ConsumerState<_CreateMapDialog> { + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Create New Map'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Map Name', + hintText: 'e.g. Travel Plan', + ), + autofocus: true, + ), + const SizedBox(height: 16), + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + hintText: 'e.g. Summer 2024 Trip', + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _isLoading ? null : _createMap, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } + + Future _createMap() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a map name')), + ); + return; + } + + setState(() => _isLoading = true); + + final description = _descriptionController.text.trim(); + final newMap = await ref.read(mapsProvider.notifier).createMap( + name: name, + description: description.isEmpty ? null : description, + ); + + if (!mounted) return; + Navigator.pop(context); + + if (newMap != null) { + ref.read(currentMapIdProvider.notifier).setCurrentMapId(newMap.id); + } + } +} + +class _EditMapDialog extends ConsumerStatefulWidget { + final MapData map; + + const _EditMapDialog({required this.map}); + + @override + ConsumerState<_EditMapDialog> createState() => _EditMapDialogState(); +} + +class _EditMapDialogState extends ConsumerState<_EditMapDialog> { + late final TextEditingController _nameController; + late final TextEditingController _descriptionController; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.map.name); + _descriptionController = TextEditingController(text: widget.map.description ?? ''); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit Map'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Map Name', + ), + autofocus: true, + ), + const SizedBox(height: 16), + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _isLoading ? null : _updateMap, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ); + } + + Future _updateMap() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a map name')), + ); + return; + } + + setState(() => _isLoading = true); + + final description = _descriptionController.text.trim(); + await ref.read(mapsProvider.notifier).updateMap( + widget.map, + name: name, + description: description.isEmpty ? null : description, + ); + + if (!mounted) return; + Navigator.pop(context); + } +} diff --git a/lib/features/map/presentation/map_screen.dart b/lib/features/map/presentation/map_screen.dart index 73dcd84..f979849 100644 --- a/lib/features/map/presentation/map_screen.dart +++ b/lib/features/map/presentation/map_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/map_provider.dart'; import 'package:memomap/features/map/providers/pin_provider.dart'; import 'package:memomap/features/map/providers/drawing_provider.dart'; import 'package:memomap/features/map/presentation/widgets/drawing_canvas.dart'; @@ -48,6 +50,9 @@ class _MapScreenState extends ConsumerState { Widget build(BuildContext context) { final isAuthenticated = ref.watch(isAuthenticatedProvider); final user = ref.watch(currentUserProvider); + final currentMapId = ref.watch(currentMapIdProvider); + final currentMap = ref.watch(currentMapProvider); + final mapsAsync = ref.watch(mapsProvider); final pinsAsync = ref.watch(pinsProvider); final drawingStateAsync = ref.watch(drawingProvider); final drawingState = drawingStateAsync.valueOrNull; @@ -57,7 +62,18 @@ class _MapScreenState extends ConsumerState { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text('Memomap'), + centerTitle: false, + title: GestureDetector( + onTap: () => context.push('/maps'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(currentMap?.name ?? (currentMapId != null ? 'Loading...' : 'Memomap')), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), actions: [ if (isAuthenticated && user != null) Padding( @@ -94,7 +110,7 @@ class _MapScreenState extends ConsumerState { ~InteractiveFlag.doubleTapZoom, ), onTap: (tapPosition, latlng) { - if (!isDrawingMode) { + if (!isDrawingMode && currentMap != null) { ref.read(pinsProvider.notifier).addPin(latlng); } }, @@ -137,9 +153,40 @@ class _MapScreenState extends ConsumerState { ], ), IgnorePointer( - ignoring: !isDrawingMode, + ignoring: !isDrawingMode || currentMap == null, child: DrawingCanvas(mapController: _mapController), ), + // Only show warning if no map is truly selected (not loading) + if (currentMap == null && currentMapId == null && !mapsAsync.isLoading) + Positioned( + top: 16, + left: 16, + right: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.amber.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.amber), + const SizedBox(width: 12), + const Expanded( + child: Text('No map selected. Create or select a map to add pins and drawings.'), + ), + TextButton( + onPressed: () => context.push('/maps'), + child: const Text('Open Maps'), + ), + ], + ), + ), + ), + ), Positioned( right: 16, bottom: 16, diff --git a/lib/features/map/presentation/widgets/controls.dart b/lib/features/map/presentation/widgets/controls.dart index f19907d..a9c3ab0 100644 --- a/lib/features/map/presentation/widgets/controls.dart +++ b/lib/features/map/presentation/widgets/controls.dart @@ -69,7 +69,7 @@ class Controls extends ConsumerWidget { ? Colors.black : Colors.grey, ), - tooltip: '元に戻す', + tooltip: 'Undo', onPressed: drawingState?.canUndo == true ? () => drawingNotifier.undo() : null, @@ -81,7 +81,7 @@ class Controls extends ConsumerWidget { ? Colors.black : Colors.grey, ), - tooltip: 'やり直す', + tooltip: 'Redo', onPressed: drawingState?.canRedo == true ? () => drawingNotifier.redo() : null, @@ -93,7 +93,7 @@ class Controls extends ConsumerWidget { ? Colors.blue : Colors.black, ), - tooltip: '消しゴム', + tooltip: 'Eraser', onPressed: () => drawingNotifier.setEraserMode( !isEraserMode, ), diff --git a/lib/features/map/providers/current_map_provider.dart b/lib/features/map/providers/current_map_provider.dart new file mode 100644 index 0000000..8d62c9e --- /dev/null +++ b/lib/features/map/providers/current_map_provider.dart @@ -0,0 +1,147 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memomap/features/map/providers/map_provider.dart'; + +final currentMapIdProvider = StateNotifierProvider((ref) { + return CurrentMapIdNotifier(ref); +}); + +class CurrentMapIdNotifier extends StateNotifier { + final Ref _ref; + bool _isCreatingDefault = false; + bool _initialLoadComplete = false; + + CurrentMapIdNotifier(this._ref) : super(null) { + _loadCurrentMapId(); + _listenToMapsChanges(); + _listenToMapIdMapping(); + } + + void _listenToMapsChanges() { + _ref.listen>>(mapsProvider, (prev, next) { + if (!_initialLoadComplete) return; + if (_isCreatingDefault) return; + + final maps = next.valueOrNull; + if (maps == null) return; + + final currentId = state; + + if (currentId == null) { + if (maps.isEmpty) { + _createDefaultMap(); + } else { + _selectFirstMap(maps); + } + return; + } + + final mapExists = maps.any((m) => m.id == currentId); + if (mapExists) return; + + if (maps.isNotEmpty) { + _selectFirstMap(maps); + } else { + _createDefaultMap(); + } + }); + } + + /// When local maps are uploaded, remap currentMapId to the new server ID. + void _listenToMapIdMapping() { + _ref.listen>(mapIdMappingProvider, (prev, next) { + if (next.isNotEmpty && state != null && next.containsKey(state)) { + setCurrentMapId(next[state]!); + } + }); + } + + Future _selectFirstMap(List maps) async { + if (!mounted || maps.isEmpty) return; + state = maps.first.id; + final syncService = await _ref.read(mapSyncServiceProvider.future); + await syncService.setCurrentMapId(maps.first.id); + } + + Future _loadCurrentMapId() async { + try { + final syncService = await _ref.read(mapSyncServiceProvider.future); + final savedMapId = await syncService.getCurrentMapId(); + + if (kDebugMode) { + debugPrint('[CurrentMapId] _loadCurrentMapId: savedMapId=$savedMapId'); + } + + if (savedMapId != null && mounted) { + state = savedMapId; + } else { + if (kDebugMode) { + debugPrint('[CurrentMapId] savedMapId is null, creating default map'); + } + await _createDefaultMap(); + } + } finally { + _initialLoadComplete = true; + } + } + + Future _createDefaultMap() async { + if (_isCreatingDefault) return; + _isCreatingDefault = true; + + try { + final mapsNotifier = _ref.read(mapsProvider.notifier); + final defaultMap = await mapsNotifier.createMap( + name: 'Default Map', + description: 'First map', + ); + if (defaultMap != null && mounted) { + state = defaultMap.id; + final syncService = await _ref.read(mapSyncServiceProvider.future); + await syncService.setCurrentMapId(defaultMap.id); + } + } finally { + _isCreatingDefault = false; + } + } + + Future setCurrentMapId(String? mapId) async { + state = mapId; + final syncService = await _ref.read(mapSyncServiceProvider.future); + await syncService.setCurrentMapId(mapId); + } + + Future ensureValidMapSelected() async { + final maps = _ref.read(mapsProvider).valueOrNull ?? []; + + if (maps.isEmpty) { + if (mounted) { + state = null; + final syncService = await _ref.read(mapSyncServiceProvider.future); + await syncService.setCurrentMapId(null); + await _createDefaultMap(); + } + return; + } + + final currentId = state; + final mapExists = maps.any((m) => m.id == currentId); + + if (!mapExists && mounted) { + state = maps.first.id; + final syncService = await _ref.read(mapSyncServiceProvider.future); + await syncService.setCurrentMapId(maps.first.id); + } + } +} + +final currentMapProvider = Provider((ref) { + final currentMapId = ref.watch(currentMapIdProvider); + final mapsAsync = ref.watch(mapsProvider); + + if (currentMapId == null) return null; + + return mapsAsync.whenOrNull( + data: (maps) => maps.where((m) => m.id == currentMapId).firstOrNull, + ); +}); diff --git a/lib/features/map/providers/drawing_provider.dart b/lib/features/map/providers/drawing_provider.dart index 4043087..346481e 100644 --- a/lib/features/map/providers/drawing_provider.dart +++ b/lib/features/map/providers/drawing_provider.dart @@ -7,6 +7,8 @@ import 'package:memomap/features/auth/providers/auth_provider.dart'; import 'package:memomap/features/map/data/drawing_repository.dart'; import 'package:memomap/features/map/data/local_drawing_storage.dart'; import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/map_provider.dart' show mapIdMappingProvider; import 'package:memomap/features/map/providers/pin_provider.dart'; import 'package:memomap/features/map/services/drawing_sync_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -43,17 +45,9 @@ class DrawingState { final double strokeWidth; final bool isDrawingMode; final bool isEraserMode; - - /// Temporary paths during eraser operation (null when not erasing) final List? eraserTempPaths; - - /// Original drawings saved at eraser operation start (for persistence) final List? eraserOriginalDrawings; - - /// Stack of previous drawing states for undo functionality final List> undoStack; - - /// Stack of undone states for redo functionality final List> redoStack; DrawingState({ @@ -68,28 +62,21 @@ class DrawingState { this.redoStack = const [], }); - /// Returns true if eraser operation is currently in progress bool get isEraserOperationActive => eraserTempPaths != null; - /// Returns eraser temp paths if in eraser operation, otherwise actual paths List get paths => eraserTempPaths ?? drawingDataList.map((d) => d.path).toList(); - /// Returns true if there are items in the undo stack bool get canUndo => undoStack.isNotEmpty; - /// Returns true if there are items in the redo stack bool get canRedo => redoStack.isNotEmpty; - /// Push current drawingDataList to undo stack (call before modifying) DrawingState pushUndo() { return copyWith( undoStack: [...undoStack, drawingDataList], ); } - /// Pop from undo stack and return (newState, restoredDrawings) - /// Returns (newState, null) if stack is empty (DrawingState, List?) popUndo() { if (undoStack.isEmpty) { return (this, null); @@ -99,15 +86,12 @@ class DrawingState { return (copyWith(undoStack: newStack), restored); } - /// Push current drawingDataList to redo stack (call before undo restores) DrawingState pushRedo() { return copyWith( redoStack: [...redoStack, drawingDataList], ); } - /// Pop from redo stack and return (newState, restoredDrawings) - /// Returns (newState, null) if stack is empty (DrawingState, List?) popRedo() { if (redoStack.isEmpty) { return (this, null); @@ -117,7 +101,6 @@ class DrawingState { return (copyWith(redoStack: newStack), restored); } - /// Clear the redo stack (call when new operation is performed) DrawingState clearRedoStack() { return copyWith(redoStack: []); } @@ -150,7 +133,6 @@ class DrawingState { } } -/// Simple async lock to serialize operations class _AsyncLock { Future? _lastOperation; @@ -178,6 +160,16 @@ final drawingProvider = class DrawingNotifier extends AsyncNotifier { final _lock = _AsyncLock(); + String? get _currentMapId => ref.read(currentMapIdProvider); + + List _filterByCurrentMap(List drawings) { + final mapId = _currentMapId; + if (mapId == null) { + return []; + } + return drawings.where((d) => d.mapId == mapId).toList(); + } + @override Future build() async { ref.listen(sessionProvider, (prev, next) { @@ -188,15 +180,23 @@ class DrawingNotifier extends AsyncNotifier { } }); + ref.listen(currentMapIdProvider, (prev, next) { + if (prev != next) { + ref.invalidateSelf(); + } + }); + final syncService = await ref.watch(drawingSyncServiceProvider.future); final session = ref.read(sessionProvider).valueOrNull; final currentUserId = session?.user.id; await syncService.clearIfUserChanged(currentUserId); - final cachedDrawings = await syncService.getAllDrawings(); + final allDrawings = await syncService.getAllDrawings(); + final filteredDrawings = _filterByCurrentMap(allDrawings); + final initialState = DrawingState( - drawingDataList: cachedDrawings, + drawingDataList: filteredDrawings, selectedColor: Colors.red, strokeWidth: 3, isDrawingMode: false, @@ -205,6 +205,10 @@ class DrawingNotifier extends AsyncNotifier { state = AsyncValue.data(initialState); if (currentUserId != null) { + final idMapping = ref.read(mapIdMappingProvider); + if (idMapping.isNotEmpty) { + await syncService.remapLocalMapIds(idMapping); + } _syncInBackground(syncService); } @@ -214,12 +218,12 @@ class DrawingNotifier extends AsyncNotifier { Future _syncInBackground(DrawingSyncService syncService) async { try { await syncService.syncWithServer(); - final freshDrawings = await syncService.getAllDrawings(); + final allDrawings = await syncService.getAllDrawings(); + final filteredDrawings = _filterByCurrentMap(allDrawings); final current = state.valueOrNull; - // Skip update if eraser operation is in progress if (current != null && current.eraserTempPaths == null) { state = AsyncValue.data( - current.copyWith(drawingDataList: freshDrawings), + current.copyWith(drawingDataList: filteredDrawings), ); } } catch (e, st) { @@ -258,9 +262,10 @@ class DrawingNotifier extends AsyncNotifier { if (current == null) return; final isAuthenticated = ref.read(isAuthenticatedProvider); + final mapId = _currentMapId; final withUndo = current.pushUndo().clearRedoStack(); - final optimisticDrawing = DrawingData.local(path); + final optimisticDrawing = DrawingData.local(path, mapId: mapId); state = AsyncValue.data( withUndo.copyWith( drawingDataList: [...withUndo.drawingDataList, optimisticDrawing], @@ -272,6 +277,7 @@ class DrawingNotifier extends AsyncNotifier { final realDrawing = await syncService.addDrawing( path: path, isAuthenticated: isAuthenticated, + mapId: mapId, ); final updated = state.valueOrNull; @@ -292,9 +298,6 @@ class DrawingNotifier extends AsyncNotifier { }); } - // ============ Eraser Operation Methods ============ - - /// Call when eraser operation starts (on pan start) void startEraserOperation() { final current = state.valueOrNull; if (current == null) return; @@ -307,7 +310,6 @@ class DrawingNotifier extends AsyncNotifier { ); } - /// Call during eraser operation to update display (on pan update) void updateEraserPaths(List paths) { final current = state.valueOrNull; if (current == null) return; @@ -317,7 +319,6 @@ class DrawingNotifier extends AsyncNotifier { ); } - /// Call when eraser operation ends (on pan end) - persists changes Future finishEraserOperation() async { return _lock.synchronized(() async { final current = state.valueOrNull; @@ -326,15 +327,13 @@ class DrawingNotifier extends AsyncNotifier { final originalDrawings = current.eraserOriginalDrawings; final tempPaths = current.eraserTempPaths; - // If no eraser operation was active, nothing to do if (originalDrawings == null || tempPaths == null) return; + final mapId = _currentMapId; final withUndo = current.pushUndo().clearRedoStack(); - // Update drawingDataList with temp paths AND clear eraser state atomically - // This prevents UI from showing old drawings during persistence final tempDrawingDataList = - tempPaths.map((p) => DrawingData.local(p)).toList(); + tempPaths.map((p) => DrawingData.local(p, mapId: mapId)).toList(); state = AsyncValue.data( withUndo.copyWith( drawingDataList: tempDrawingDataList, @@ -343,7 +342,6 @@ class DrawingNotifier extends AsyncNotifier { ), ); - // Persist changes in background try { final isAuthenticated = ref.read(isAuthenticatedProvider); final syncService = await ref.read(drawingSyncServiceProvider.future); @@ -352,19 +350,19 @@ class DrawingNotifier extends AsyncNotifier { oldDrawings: originalDrawings, newPaths: tempPaths, isAuthenticated: isAuthenticated, + mapId: mapId, ); final updated = state.valueOrNull; if (updated != null) { state = AsyncValue.data( - updated.copyWith(drawingDataList: newDrawingDataList), + updated.copyWith(drawingDataList: _filterByCurrentMap(newDrawingDataList)), ); } } catch (e, st) { if (kDebugMode) { debugPrint('Failed to persist eraser changes: $e\n$st'); } - // On error, tempDrawingDataList is already set, so no action needed } }); } @@ -398,21 +396,17 @@ class DrawingNotifier extends AsyncNotifier { return _lock.synchronized(() async { final current = state.valueOrNull; if (current == null) return; - // Skip undo during eraser operation to prevent inconsistency if (current.isEraserOperationActive) return; if (!current.canUndo) return; - // Push current to redo stack, then pop from undo stack final withRedo = current.pushRedo(); final (newState, restoredDrawings) = withRedo.popUndo(); if (restoredDrawings == null) return; - // Update UI immediately state = AsyncValue.data( newState.copyWith(drawingDataList: restoredDrawings), ); - // Persist the restored state try { final isAuthenticated = ref.read(isAuthenticatedProvider); final syncService = await ref.read(drawingSyncServiceProvider.future); @@ -421,12 +415,13 @@ class DrawingNotifier extends AsyncNotifier { oldDrawings: current.drawingDataList, newDrawings: restoredDrawings, isAuthenticated: isAuthenticated, + mapId: _currentMapId, ); final updated = state.valueOrNull; if (updated != null) { state = AsyncValue.data( - updated.copyWith(drawingDataList: persistedDrawings), + updated.copyWith(drawingDataList: _filterByCurrentMap(persistedDrawings)), ); } } catch (e, st) { @@ -441,21 +436,17 @@ class DrawingNotifier extends AsyncNotifier { return _lock.synchronized(() async { final current = state.valueOrNull; if (current == null) return; - // Skip redo during eraser operation to prevent inconsistency if (current.isEraserOperationActive) return; if (!current.canRedo) return; - // Push current to undo stack, then pop from redo stack final withUndo = current.pushUndo(); final (newState, restoredDrawings) = withUndo.popRedo(); if (restoredDrawings == null) return; - // Update UI immediately state = AsyncValue.data( newState.copyWith(drawingDataList: restoredDrawings), ); - // Persist the restored state try { final isAuthenticated = ref.read(isAuthenticatedProvider); final syncService = await ref.read(drawingSyncServiceProvider.future); @@ -464,12 +455,13 @@ class DrawingNotifier extends AsyncNotifier { oldDrawings: current.drawingDataList, newDrawings: restoredDrawings, isAuthenticated: isAuthenticated, + mapId: _currentMapId, ); final updated = state.valueOrNull; if (updated != null) { state = AsyncValue.data( - updated.copyWith(drawingDataList: persistedDrawings), + updated.copyWith(drawingDataList: _filterByCurrentMap(persistedDrawings)), ); } } catch (e, st) { diff --git a/lib/features/map/providers/map_provider.dart b/lib/features/map/providers/map_provider.dart new file mode 100644 index 0000000..ecb24a5 --- /dev/null +++ b/lib/features/map/providers/map_provider.dart @@ -0,0 +1,146 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/data/local_map_storage.dart'; +import 'package:memomap/features/map/data/map_repository.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/pin_provider.dart'; +import 'package:memomap/features/map/services/map_sync_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +export 'package:memomap/features/map/data/map_repository.dart' show MapData; + +final localMapStorageProvider = Provider((ref) { + final prefs = SharedPreferencesAsync(); + return SharedPreferencesLocalMapStorage(prefs); +}); + +final mapRepositoryProvider = FutureProvider((ref) async { + return MapRepository.getInstance(); +}); + +final mapSyncServiceProvider = FutureProvider((ref) async { + final storage = ref.watch(localMapStorageProvider); + final networkChecker = ref.watch(networkCheckerProvider); + final repository = await ref.watch(mapRepositoryProvider.future); + + return MapSyncService( + storage: storage, + networkChecker: networkChecker, + repository: repository, + ); +}); + +/// Holds the local→server map ID mapping from the last sync. +/// Used by pin/drawing/currentMap providers to remap IDs after local maps are uploaded. +final mapIdMappingProvider = StateProvider>((ref) => {}); + +final mapsProvider = AsyncNotifierProvider>(() { + return MapsNotifier(); +}); + +class MapsNotifier extends AsyncNotifier> { + @override + Future> build() async { + final syncService = await ref.watch(mapSyncServiceProvider.future); + + // ref.watch triggers rebuild when session changes. + // Do NOT use ref.listen + invalidateSelf together with ref.watch + // on the same provider — it causes concurrent builds and double uploads. + final session = await ref.watch(sessionProvider.future); + final currentUserId = session?.user.id; + final isAuthenticated = ref.read(isAuthenticatedProvider); + + // Clear before reading to avoid briefly showing previous user's data + await syncService.clearIfUserChanged(currentUserId); + + final cachedMaps = await syncService.getAllMaps(); + state = AsyncValue.data(cachedMaps); + + if (isAuthenticated) { + final idMapping = await syncService.syncWithServer(); + + if (idMapping.isNotEmpty) { + ref.read(mapIdMappingProvider.notifier).state = idMapping; + } + + final freshMaps = await syncService.getAllMaps(); + state = AsyncValue.data(freshMaps); + return freshMaps; + } + + return cachedMaps; + } + + Future createMap({required String name, String? description}) async { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(mapSyncServiceProvider.future); + + try { + final newMap = await syncService.createMap( + name: name, + description: description, + isAuthenticated: isAuthenticated, + ); + + state = AsyncValue.data([newMap, ...(state.value ?? [])]); + return newMap; + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to create map: $e\n$st'); + } + return null; + } + } + + Future updateMap(MapData map, {String? name, String? description}) async { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(mapSyncServiceProvider.future); + + try { + final updatedMap = await syncService.updateMap( + map: map, + name: name, + description: description, + isAuthenticated: isAuthenticated, + ); + + if (updatedMap != null) { + state = AsyncValue.data( + (state.value ?? []).map((m) => m.id == updatedMap.id ? updatedMap : m).toList(), + ); + } + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to update map: $e\n$st'); + } + } + } + + Future deleteMap(MapData map) async { + final isAuthenticated = ref.read(isAuthenticatedProvider); + final syncService = await ref.read(mapSyncServiceProvider.future); + + state = AsyncValue.data( + (state.value ?? []).where((m) => m.id != map.id).toList(), + ); + + try { + await syncService.deleteMap( + map: map, + isAuthenticated: isAuthenticated, + ); + } catch (e, st) { + if (kDebugMode) { + debugPrint('Failed to delete map: $e\n$st'); + } + } + + await ref.read(currentMapIdProvider.notifier).ensureValidMapSelected(); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => build()); + } +} diff --git a/lib/features/map/providers/pin_provider.dart b/lib/features/map/providers/pin_provider.dart index f7edd3c..1f91359 100644 --- a/lib/features/map/providers/pin_provider.dart +++ b/lib/features/map/providers/pin_provider.dart @@ -5,6 +5,8 @@ import 'package:memomap/features/auth/providers/auth_provider.dart'; import 'package:memomap/features/map/data/local_pin_storage.dart'; import 'package:memomap/features/map/data/network_checker.dart'; import 'package:memomap/features/map/data/pin_repository.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/map_provider.dart' show mapIdMappingProvider; import 'package:memomap/features/map/services/pin_sync_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,6 +42,16 @@ final pinsProvider = AsyncNotifierProvider>(() { }); class PinsNotifier extends AsyncNotifier> { + String? get _currentMapId => ref.read(currentMapIdProvider); + + List _filterByCurrentMap(List pins) { + final mapId = _currentMapId; + if (mapId == null) { + return []; + } + return pins.where((p) => p.mapId == mapId).toList(); + } + @override Future> build() async { ref.listen(sessionProvider, (prev, next) { @@ -50,27 +62,39 @@ class PinsNotifier extends AsyncNotifier> { } }); + ref.listen(currentMapIdProvider, (prev, next) { + if (prev != next) { + ref.invalidateSelf(); + } + }); + final syncService = await ref.watch(pinSyncServiceProvider.future); final session = ref.read(sessionProvider).valueOrNull; final currentUserId = session?.user.id; + // Clear before reading to avoid briefly showing previous user's data await syncService.clearIfUserChanged(currentUserId); - final cachedPins = await syncService.getAllPins(); - state = AsyncValue.data(cachedPins); + final allPins = await syncService.getAllPins(); + final filteredPins = _filterByCurrentMap(allPins); + state = AsyncValue.data(filteredPins); if (currentUserId != null) { + final idMapping = ref.read(mapIdMappingProvider); + if (idMapping.isNotEmpty) { + await syncService.remapLocalMapIds(idMapping); + } _syncInBackground(syncService); } - return cachedPins; + return filteredPins; } Future _syncInBackground(PinSyncService syncService) async { try { await syncService.syncWithServer(); - final freshPins = await syncService.getAllPins(); - state = AsyncValue.data(freshPins); + final allPins = await syncService.getAllPins(); + state = AsyncValue.data(_filterByCurrentMap(allPins)); } catch (e, st) { if (kDebugMode) { debugPrint('Background sync failed: $e\n$st'); @@ -81,15 +105,17 @@ class PinsNotifier extends AsyncNotifier> { Future addPin(LatLng position) async { final isAuthenticated = ref.read(isAuthenticatedProvider); final syncService = await ref.read(pinSyncServiceProvider.future); + final mapId = _currentMapId; final previous = state.value ?? []; - final optimisticPin = PinData.local(position); + final optimisticPin = PinData.local(position, mapId: mapId); state = AsyncValue.data([optimisticPin, ...previous]); try { final realPin = await syncService.addPin( position: position, isAuthenticated: isAuthenticated, + mapId: mapId, ); state = AsyncValue.data( diff --git a/lib/features/map/services/drawing_sync_service.dart b/lib/features/map/services/drawing_sync_service.dart index fc416fd..e83f84c 100644 --- a/lib/features/map/services/drawing_sync_service.dart +++ b/lib/features/map/services/drawing_sync_service.dart @@ -25,34 +25,35 @@ class DrawingSyncService { Future addDrawing({ required DrawingPath path, required bool isAuthenticated, + String? mapId, }) async { if (!isAuthenticated) { - return _addLocalDrawing(path); + return _addLocalDrawing(path, mapId: mapId); } final isOnline = await networkChecker.isOnline; if (!isOnline) { - return _addLocalDrawing(path); + return _addLocalDrawing(path, mapId: mapId); } try { - final serverDrawing = await repository.addDrawing(path); + final serverDrawing = await repository.addDrawing(path, mapId: mapId); if (serverDrawing != null) { final cachedDrawings = await storage.getCachedDrawings(); await storage.setCachedDrawings([serverDrawing, ...cachedDrawings]); return serverDrawing; } - return _addLocalDrawing(path); + return _addLocalDrawing(path, mapId: mapId); } catch (e) { if (kDebugMode) { debugPrint('Failed to add drawing to server: $e'); } - return _addLocalDrawing(path); + return _addLocalDrawing(path, mapId: mapId); } } - Future _addLocalDrawing(DrawingPath path) async { - final localDrawing = DrawingData.local(path); + Future _addLocalDrawing(DrawingPath path, {String? mapId}) async { + final localDrawing = DrawingData.local(path, mapId: mapId); final localDrawings = await storage.getLocalDrawings(); await storage.setLocalDrawings([localDrawing, ...localDrawings]); return localDrawing; @@ -100,6 +101,26 @@ class DrawingSyncService { await storage.setPendingDeletions([...pendingDeletions, drawingId]); } + Future remapLocalMapIds(Map idMapping) async { + if (idMapping.isEmpty) return; + + final localDrawings = await storage.getLocalDrawings(); + final updated = localDrawings.map((drawing) { + if (drawing.mapId != null && idMapping.containsKey(drawing.mapId)) { + return DrawingData( + id: drawing.id, + userId: drawing.userId, + mapId: idMapping[drawing.mapId], + path: drawing.path, + createdAt: drawing.createdAt, + isLocal: drawing.isLocal, + ); + } + return drawing; + }).toList(); + await storage.setLocalDrawings(updated); + } + /// Replaces old drawings with new drawings/paths, handling deletions and additions. /// /// When [newDrawings] is provided, uses ID-based comparison (for undo/redo). @@ -109,6 +130,7 @@ class DrawingSyncService { List? newPaths, List? newDrawings, required bool isAuthenticated, + String? mapId, }) async { assert(newPaths != null || newDrawings != null); @@ -126,6 +148,7 @@ class DrawingSyncService { final added = await addDrawing( path: newDrawing.path, isAuthenticated: isAuthenticated, + mapId: mapId, ); result.add(added); } @@ -147,6 +170,7 @@ class DrawingSyncService { final newDrawing = await addDrawing( path: newPath, isAuthenticated: isAuthenticated, + mapId: mapId, ); result.add(newDrawing); } diff --git a/lib/features/map/services/map_sync_service.dart b/lib/features/map/services/map_sync_service.dart new file mode 100644 index 0000000..b3064ea --- /dev/null +++ b/lib/features/map/services/map_sync_service.dart @@ -0,0 +1,194 @@ +import 'package:flutter/foundation.dart'; +import 'package:memomap/features/map/data/local_map_storage.dart'; +import 'package:memomap/features/map/data/map_repository.dart'; +import 'package:memomap/features/map/data/network_checker.dart'; + +class MapSyncService { + final LocalMapStorageBase storage; + final NetworkCheckerBase networkChecker; + final MapRepositoryBase repository; + + MapSyncService({ + required this.storage, + required this.networkChecker, + required this.repository, + }); + + Future clearIfUserChanged(String? currentUserId) async { + final lastUserId = await storage.getLastUserId(); + + if (kDebugMode) { + debugPrint('[MapSync] clearIfUserChanged: lastUserId=$lastUserId, currentUserId=$currentUserId'); + } + + if (lastUserId != null && lastUserId != currentUserId) { + if (kDebugMode) { + debugPrint('[MapSync] User changed, clearing all data'); + } + await storage.clearAll(); + } + + await storage.setLastUserId(currentUserId); + } + + Future> getAllMaps() async { + final cachedMaps = await storage.getCachedMaps(); + final localMaps = await storage.getLocalMaps(); + + return [...cachedMaps, ...localMaps]; + } + + Future createMap({ + required String name, + String? description, + required bool isAuthenticated, + }) async { + final isOnline = await networkChecker.isOnline; + + if (isAuthenticated && isOnline) { + final serverMap = await repository.createMap( + name: name, + description: description, + ); + if (serverMap != null) { + final cachedMaps = await storage.getCachedMaps(); + await storage.setCachedMaps([serverMap, ...cachedMaps]); + return serverMap; + } + } + + final localMap = MapData.local(name: name, description: description); + final localMaps = await storage.getLocalMaps(); + await storage.setLocalMaps([localMap, ...localMaps]); + return localMap; + } + + Future updateMap({ + required MapData map, + String? name, + String? description, + required bool isAuthenticated, + }) async { + final isOnline = await networkChecker.isOnline; + + if (isAuthenticated && isOnline && !map.isLocal) { + final serverMap = await repository.updateMap( + map.id, + name: name, + description: description, + ); + if (serverMap != null) { + final cachedMaps = await storage.getCachedMaps(); + final updatedCachedMaps = cachedMaps + .map((m) => m.id == serverMap.id ? serverMap : m) + .toList(); + await storage.setCachedMaps(updatedCachedMaps); + return serverMap; + } + } + + if (map.isLocal) { + final localMaps = await storage.getLocalMaps(); + final updatedMap = map.copyWith( + name: name ?? map.name, + description: description ?? map.description, + ); + final updatedLocalMaps = localMaps + .map((m) => m.id == map.id ? updatedMap : m) + .toList(); + await storage.setLocalMaps(updatedLocalMaps); + return updatedMap; + } + + return null; + } + + Future deleteMap({ + required MapData map, + required bool isAuthenticated, + }) async { + if (map.isLocal) { + final localMaps = await storage.getLocalMaps(); + await storage.setLocalMaps( + localMaps.where((m) => m.id != map.id).toList(), + ); + return; + } + + final isOnline = await networkChecker.isOnline; + + if (isAuthenticated && isOnline) { + await repository.deleteMap(map.id); + } else { + await _addToPendingDeletions(map.id); + } + + final cachedMaps = await storage.getCachedMaps(); + await storage.setCachedMaps( + cachedMaps.where((m) => m.id != map.id).toList(), + ); + } + + Future _addToPendingDeletions(String mapId) async { + final pendingDeletions = await storage.getPendingDeletions(); + await storage.setPendingDeletions([...pendingDeletions, mapId]); + } + + /// Syncs with server. Returns a mapping of old local map IDs to new server IDs. + Future> syncWithServer() async { + final isOnline = await networkChecker.isOnline; + if (!isOnline) return {}; + + await _processPendingDeletions(); + + final localMaps = await storage.getLocalMaps(); + var idMapping = {}; + + if (localMaps.isNotEmpty) { + idMapping = await repository.uploadLocalMaps(localMaps); + if (idMapping.isNotEmpty) { + await storage.setLocalMaps([]); + } + } + + final serverMaps = await repository.getMaps(); + await storage.setCachedMaps(serverMaps); + + return idMapping; + } + + Future _processPendingDeletions() async { + final pendingDeletions = await storage.getPendingDeletions(); + if (pendingDeletions.isEmpty) return; + + final failedDeletions = []; + + for (final mapId in pendingDeletions) { + try { + await repository.deleteMap(mapId); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to delete map $mapId: $e'); + } + failedDeletions.add(mapId); + } + } + + await storage.setPendingDeletions(failedDeletions); + } + + Future getCurrentMapId() async { + final mapId = await storage.getCurrentMapId(); + if (kDebugMode) { + debugPrint('[MapSync] getCurrentMapId: $mapId'); + } + return mapId; + } + + Future setCurrentMapId(String? mapId) async { + if (kDebugMode) { + debugPrint('[MapSync] setCurrentMapId: $mapId'); + } + await storage.setCurrentMapId(mapId); + } +} diff --git a/lib/features/map/services/pin_sync_service.dart b/lib/features/map/services/pin_sync_service.dart index 25dfb90..47b1a99 100644 --- a/lib/features/map/services/pin_sync_service.dart +++ b/lib/features/map/services/pin_sync_service.dart @@ -25,34 +25,35 @@ class PinSyncService { Future addPin({ required LatLng position, required bool isAuthenticated, + String? mapId, }) async { if (!isAuthenticated) { - return _addLocalPin(position); + return _addLocalPin(position, mapId: mapId); } final isOnline = await networkChecker.isOnline; if (!isOnline) { - return _addLocalPin(position); + return _addLocalPin(position, mapId: mapId); } try { - final serverPin = await repository.addPin(position); + final serverPin = await repository.addPin(position, mapId: mapId); if (serverPin != null) { final cachedPins = await storage.getCachedPins(); await storage.setCachedPins([serverPin, ...cachedPins]); return serverPin; } - return _addLocalPin(position); + return _addLocalPin(position, mapId: mapId); } catch (e) { if (kDebugMode) { debugPrint('Failed to add pin to server: $e'); } - return _addLocalPin(position); + return _addLocalPin(position, mapId: mapId); } } - Future _addLocalPin(LatLng position) async { - final localPin = PinData.local(position); + Future _addLocalPin(LatLng position, {String? mapId}) async { + final localPin = PinData.local(position, mapId: mapId); final localPins = await storage.getLocalPins(); await storage.setLocalPins([localPin, ...localPins]); return localPin; @@ -100,6 +101,26 @@ class PinSyncService { await storage.setPendingDeletions([...pendingDeletions, pinId]); } + Future remapLocalMapIds(Map idMapping) async { + if (idMapping.isEmpty) return; + + final localPins = await storage.getLocalPins(); + final updated = localPins.map((pin) { + if (pin.mapId != null && idMapping.containsKey(pin.mapId)) { + return PinData( + id: pin.id, + userId: pin.userId, + mapId: idMapping[pin.mapId], + position: pin.position, + createdAt: pin.createdAt, + isLocal: pin.isLocal, + ); + } + return pin; + }).toList(); + await storage.setLocalPins(updated); + } + Future syncWithServer() async { final isOnline = await networkChecker.isOnline; if (!isOnline) return; diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 6b95be3..d2116a7 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:memomap/features/auth/presentation/login_screen.dart'; import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/presentation/map_list_screen.dart'; import 'package:memomap/features/map/presentation/map_screen.dart'; import 'package:memomap/features/profile/presentation/profile_screen.dart'; @@ -42,6 +43,10 @@ final routerProvider = Provider((ref) { return auth ? null : '/login'; }, ), + GoRoute( + path: '/maps', + builder: (context, state) => const MapListScreen(), + ), GoRoute( path: '/auth-callback', builder: (context, state) => const AuthCallbackScreen(), diff --git a/pubspec.lock b/pubspec.lock index 7a886e0..4aceeda 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -489,7 +489,7 @@ packages: source: hosted version: "4.1.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/pubspec.yaml b/pubspec.yaml index baf8df7..6e9b336 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: jwt_decoder: ^2.0.1 shared_preferences: ^2.3.3 connectivity_plus: ^6.1.0 + intl: ^0.20.2 dev_dependencies: flutter_test: diff --git a/test/features/map/mocks/mocks.dart b/test/features/map/mocks/mocks.dart index f8b9080..13cbb77 100644 --- a/test/features/map/mocks/mocks.dart +++ b/test/features/map/mocks/mocks.dart @@ -1,6 +1,8 @@ import 'package:memomap/features/map/data/drawing_repository_base.dart'; import 'package:memomap/features/map/data/local_drawing_storage.dart'; +import 'package:memomap/features/map/data/local_map_storage.dart'; import 'package:memomap/features/map/data/local_pin_storage.dart'; +import 'package:memomap/features/map/data/map_repository.dart'; import 'package:memomap/features/map/data/network_checker.dart'; import 'package:memomap/features/map/data/pin_repository_base.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,3 +16,7 @@ class MockPinRepository extends Mock implements PinRepositoryBase {} class MockLocalDrawingStorage extends Mock implements LocalDrawingStorageBase {} class MockDrawingRepository extends Mock implements DrawingRepositoryBase {} + +class MockLocalMapStorage extends Mock implements LocalMapStorageBase {} + +class MockMapRepository extends Mock implements MapRepositoryBase {} diff --git a/test/features/map/providers/current_map_provider_test.dart b/test/features/map/providers/current_map_provider_test.dart new file mode 100644 index 0000000..437009f --- /dev/null +++ b/test/features/map/providers/current_map_provider_test.dart @@ -0,0 +1,276 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:memomap/features/auth/providers/auth_provider.dart'; +import 'package:memomap/features/map/data/map_repository.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; +import 'package:memomap/features/map/providers/map_provider.dart'; +import 'package:memomap/features/map/services/map_sync_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMapSyncService extends Mock implements MapSyncService {} + +void main() { + group('CurrentMapIdNotifier', () { + late MockMapSyncService mockSyncService; + + setUpAll(() { + registerFallbackValue(MapData( + id: 'fallback', + userId: null, + name: 'Fallback', + description: null, + createdAt: DateTime.now(), + isLocal: true, + )); + }); + + setUp(() { + mockSyncService = MockMapSyncService(); + when(() => mockSyncService.clearIfUserChanged(any())) + .thenAnswer((_) async {}); + when(() => mockSyncService.syncWithServer()) + .thenAnswer((_) async => {}); + when(() => mockSyncService.setCurrentMapId(any())) + .thenAnswer((_) async {}); + }); + + test('should NOT create duplicate default map when reloading with existing map', () async { + // Setup: 1 Default Map exists, saved as current + final existingMap = MapData( + id: 'existing-default-map', + userId: null, + name: 'Default Map', + description: 'First map', + createdAt: DateTime.now(), + isLocal: true, + ); + + when(() => mockSyncService.getAllMaps()) + .thenAnswer((_) async => [existingMap]); + when(() => mockSyncService.getCurrentMapId()) + .thenAnswer((_) async => existingMap.id); + + final container = ProviderContainer( + overrides: [ + mapSyncServiceProvider.overrideWith((ref) async => mockSyncService), + sessionProvider.overrideWith((ref) async => null), + isAuthenticatedProvider.overrideWithValue(false), + ], + ); + + // Trigger currentMapIdProvider to initialize + container.read(currentMapIdProvider); + + // Wait for async initialization to complete + await Future.delayed(const Duration(milliseconds: 500)); + + // Verify: getCurrentMapId was called (proves initialization ran) + verify(() => mockSyncService.getCurrentMapId()).called(1); + + // Verify: createMap should NOT be called + verifyNever(() => mockSyncService.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + isAuthenticated: any(named: 'isAuthenticated'), + )); + + // Verify: current map is the existing one + expect(container.read(currentMapIdProvider), existingMap.id); + + // Verify: only 1 map exists + final maps = await container.read(mapsProvider.future); + expect(maps.length, 1); + + container.dispose(); + }); + + test('should create default when cache is empty and savedMapId is stale', () async { + // Scenario: savedMapId exists but maps list is empty (unauthenticated). + // The saved map is unrecoverable, so a default map should be created. + final savedMapId = 'saved-map-id'; + + when(() => mockSyncService.getAllMaps()).thenAnswer((_) async => []); + when(() => mockSyncService.getCurrentMapId()) + .thenAnswer((_) async => savedMapId); + + final createdMap = MapData( + id: 'new-default-map', + userId: null, + name: 'Default Map', + description: 'First map', + createdAt: DateTime.now(), + isLocal: true, + ); + when(() => mockSyncService.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async => createdMap); + + final container = ProviderContainer( + overrides: [ + mapSyncServiceProvider.overrideWith((ref) async => mockSyncService), + sessionProvider.overrideWith((ref) async => null), + isAuthenticatedProvider.overrideWithValue(false), + ], + ); + + // Trigger initialization + container.read(currentMapIdProvider); + await Future.delayed(const Duration(milliseconds: 500)); + + // Should create a default map because the saved map is unrecoverable + verify(() => mockSyncService.createMap( + name: 'Default Map', + description: 'First map', + isAuthenticated: false, + )).called(greaterThanOrEqualTo(1)); + + container.dispose(); + }); + + test('should NOT create new default when reloading with existing default map and pins', () async { + // Scenario: User created default map, added pins, then reloads + final existingDefault = MapData( + id: 'default-map-with-pins', + userId: null, + name: 'Default Map', + description: 'First map', + createdAt: DateTime.now(), + isLocal: true, + ); + + when(() => mockSyncService.getAllMaps()) + .thenAnswer((_) async => [existingDefault]); + when(() => mockSyncService.getCurrentMapId()) + .thenAnswer((_) async => existingDefault.id); + + final container = ProviderContainer( + overrides: [ + mapSyncServiceProvider.overrideWith((ref) async => mockSyncService), + sessionProvider.overrideWith((ref) async => null), + isAuthenticatedProvider.overrideWithValue(false), + ], + ); + + container.read(currentMapIdProvider); + await Future.delayed(const Duration(milliseconds: 500)); + + // Should NOT create a new map + verifyNever(() => mockSyncService.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + isAuthenticated: any(named: 'isAuthenticated'), + )); + + expect(container.read(currentMapIdProvider), existingDefault.id); + + container.dispose(); + }); + + test('should create default map via ensureValidMapSelected when maps is empty', () async { + // Scenario: After sync, maps list is empty and currentMapId is stale. + // ensureValidMapSelected() should create a default map. + final staleMapId = 'stale-map-id'; + + when(() => mockSyncService.getAllMaps()) + .thenAnswer((_) async => []); + when(() => mockSyncService.getCurrentMapId()) + .thenAnswer((_) async => staleMapId); + + final createdMap = MapData( + id: 'new-default-map', + userId: null, + name: 'Default Map', + description: 'First map', + createdAt: DateTime.now(), + isLocal: true, + ); + when(() => mockSyncService.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async => createdMap); + + final container = ProviderContainer( + overrides: [ + mapSyncServiceProvider.overrideWith((ref) async => mockSyncService), + sessionProvider.overrideWith((ref) async => null), + isAuthenticatedProvider.overrideWithValue(false), + ], + ); + + // Initialize providers + container.read(currentMapIdProvider); + await Future.delayed(const Duration(milliseconds: 300)); + + // Simulate post-sync: maps list is empty + container.read(mapsProvider.notifier).state = + const AsyncValue.data([]); + + // Call ensureValidMapSelected (as MapsNotifier.build() does after sync) + await container + .read(currentMapIdProvider.notifier) + .ensureValidMapSelected(); + + await Future.delayed(const Duration(milliseconds: 300)); + + // Should have created a default map + verify(() => mockSyncService.createMap( + name: 'Default Map', + description: 'First map', + isAuthenticated: false, + )).called(greaterThanOrEqualTo(1)); + + container.dispose(); + }); + + test('should create default map when savedMapId is null (first launch)', () async { + // savedMapId == null means no map has been created yet + // So we should create a default map + when(() => mockSyncService.getAllMaps()).thenAnswer((_) async => []); + when(() => mockSyncService.getCurrentMapId()) + .thenAnswer((_) async => null); + + final createdMap = MapData( + id: 'new-default-map', + userId: null, + name: 'Default Map', + description: 'First map', + createdAt: DateTime.now(), + isLocal: true, + ); + when(() => mockSyncService.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + isAuthenticated: any(named: 'isAuthenticated'), + )).thenAnswer((_) async => createdMap); + + final container = ProviderContainer( + overrides: [ + mapSyncServiceProvider.overrideWith((ref) async => mockSyncService), + sessionProvider.overrideWith((ref) async => null), + isAuthenticatedProvider.overrideWithValue(false), + ], + ); + + // Trigger initialization + container.read(currentMapIdProvider); + await Future.delayed(const Duration(milliseconds: 500)); + + // Should create a default map because savedMapId is null. + // May be called more than once in test due to mock getAllMaps always + // returning [] (in real app, createMap updates mapsProvider state). + verify(() => mockSyncService.createMap( + name: 'Default Map', + description: 'First map', + isAuthenticated: false, + )).called(greaterThanOrEqualTo(1)); + + // currentMapId should be the new map + expect(container.read(currentMapIdProvider), createdMap.id); + + container.dispose(); + }); + }); +} diff --git a/test/features/map/providers/drawing_notifier_test.dart b/test/features/map/providers/drawing_notifier_test.dart index ffa815b..26ace9b 100644 --- a/test/features/map/providers/drawing_notifier_test.dart +++ b/test/features/map/providers/drawing_notifier_test.dart @@ -9,6 +9,7 @@ import 'package:memomap/features/map/data/drawing_repository.dart'; import 'package:memomap/features/map/data/local_drawing_storage.dart'; import 'package:memomap/features/map/data/network_checker.dart'; import 'package:memomap/features/map/models/drawing_path.dart'; +import 'package:memomap/features/map/providers/current_map_provider.dart'; import 'package:memomap/features/map/providers/drawing_provider.dart'; import 'package:memomap/features/map/services/drawing_sync_service.dart'; import 'package:mocktail/mocktail.dart'; @@ -40,7 +41,7 @@ void main() { return DrawingData( id: id, userId: 'user-123', - mapId: null, + mapId: 'test-map-id', path: path, createdAt: DateTime.utc(2024, 1, 15), ); @@ -52,7 +53,7 @@ void main() { registerFallbackValue(DrawingData( id: 'fallback', userId: 'user', - mapId: null, + mapId: 'test-map-id', path: testPath1, createdAt: DateTime.utc(2024, 1, 1), )); @@ -87,6 +88,7 @@ void main() { (ref) async => null, ), isAuthenticatedProvider.overrideWith((ref) => false), + currentMapIdProvider.overrideWith((ref) => _MockCurrentMapIdNotifier()), ], ); } @@ -113,6 +115,7 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) async { final drawing = createDrawing('b', testPath2); serverDrawings.add(drawing.id); @@ -123,6 +126,7 @@ void main() { oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((invocation) async { final oldDrawings = invocation.namedArguments[#oldDrawings] as List; @@ -185,6 +189,7 @@ void main() { oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((invocation) async { final newDrawings = invocation.namedArguments[#newDrawings] as List; @@ -236,6 +241,7 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) async => createDrawing('c', testPath1)); await notifier.addPath(testPath1); @@ -246,12 +252,14 @@ void main() { oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) => undoCompleter.future); final addPathCompleter = Completer(); when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) => addPathCompleter.future); // Start undo (will wait on undoCompleter) @@ -304,6 +312,7 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) { callCount++; switch (callCount) { @@ -365,6 +374,7 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((inv) async { final path = inv.namedArguments[#path] as DrawingPath; return createDrawing( @@ -385,6 +395,7 @@ void main() { oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) { undoCallCount++; if (undoCallCount == 1) { @@ -429,12 +440,14 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) async => createDrawing('b', testPath2)); when(() => mockSyncService.replaceDrawings( oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((inv) async { return inv.namedArguments[#newDrawings] as List; }); @@ -455,6 +468,7 @@ void main() { oldDrawings: any(named: 'oldDrawings'), newDrawings: any(named: 'newDrawings'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) { callCount++; if (callCount == 1) { @@ -502,6 +516,7 @@ void main() { when(() => mockSyncService.addDrawing( path: any(named: 'path'), isAuthenticated: any(named: 'isAuthenticated'), + mapId: any(named: 'mapId'), )).thenAnswer((_) { addDrawingCallCount++; if (addDrawingCallCount == 1) { @@ -535,3 +550,15 @@ void main() { ); }); } + +class _MockCurrentMapIdNotifier extends StateNotifier implements CurrentMapIdNotifier { + _MockCurrentMapIdNotifier() : super('test-map-id'); + + @override + Future setCurrentMapId(String? mapId) async { + state = mapId; + } + + @override + Future ensureValidMapSelected() async {} +} diff --git a/test/features/map/services/drawing_sync_service_test.dart b/test/features/map/services/drawing_sync_service_test.dart index 2df855e..cb674ee 100644 --- a/test/features/map/services/drawing_sync_service_test.dart +++ b/test/features/map/services/drawing_sync_service_test.dart @@ -823,6 +823,53 @@ void main() { }); }); + group('remapLocalMapIds', () { + test('should update mapIds of local drawings matching the mapping', + () async { + final localDrawings = [ + DrawingData( + id: 'drawing-1', + userId: null, + mapId: 'local-map-1', + path: testPath, + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ), + DrawingData( + id: 'drawing-2', + userId: null, + mapId: 'local-map-2', + path: testPath, + createdAt: DateTime.utc(2024, 1, 16), + isLocal: true, + ), + ]; + + when(() => mockStorage.getLocalDrawings()) + .thenAnswer((_) async => localDrawings); + when(() => mockStorage.setLocalDrawings(any())) + .thenAnswer((_) async {}); + + await service.remapLocalMapIds({ + 'local-map-1': 'server-map-1', + }); + + final captured = + verify(() => mockStorage.setLocalDrawings(captureAny())).captured; + final updatedDrawings = captured.last as List; + + expect(updatedDrawings[0].mapId, 'server-map-1'); + expect(updatedDrawings[1].mapId, 'local-map-2'); + }); + + test('should do nothing when mapping is empty', () async { + await service.remapLocalMapIds({}); + + verifyNever(() => mockStorage.getLocalDrawings()); + verifyNever(() => mockStorage.setLocalDrawings(any())); + }); + }); + group('clearIfUserChanged', () { test('clears all when user changes', () async { when(() => mockStorage.getLastUserId()) diff --git a/test/features/map/services/map_sync_service_test.dart b/test/features/map/services/map_sync_service_test.dart new file mode 100644 index 0000000..8e08640 --- /dev/null +++ b/test/features/map/services/map_sync_service_test.dart @@ -0,0 +1,403 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:memomap/features/map/data/map_repository.dart'; +import 'package:memomap/features/map/services/map_sync_service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../mocks/mocks.dart'; + +void main() { + late MockLocalMapStorage mockStorage; + late MockNetworkChecker mockNetworkChecker; + late MockMapRepository mockRepository; + late MapSyncService syncService; + + setUpAll(() { + registerFallbackValue([]); + registerFallbackValue([]); + }); + + setUp(() { + mockStorage = MockLocalMapStorage(); + mockNetworkChecker = MockNetworkChecker(); + mockRepository = MockMapRepository(); + + syncService = MapSyncService( + storage: mockStorage, + networkChecker: mockNetworkChecker, + repository: mockRepository, + ); + }); + + group('MapSyncService', () { + group('getAllMaps', () { + test('should return cached + local maps', () async { + final cachedMaps = [ + MapData( + id: 'cached-1', + userId: 'user-1', + name: 'Cached Map', + createdAt: DateTime.utc(2024, 1, 15), + ), + ]; + final localMaps = [ + MapData( + id: 'local-1', + userId: null, + name: 'Local Map', + createdAt: DateTime.utc(2024, 1, 16), + isLocal: true, + ), + ]; + + when(() => mockStorage.getCachedMaps()) + .thenAnswer((_) async => cachedMaps); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => localMaps); + + final maps = await syncService.getAllMaps(); + + expect(maps.length, 2); + expect(maps.any((m) => m.id == 'cached-1'), true); + expect(maps.any((m) => m.id == 'local-1'), true); + }); + }); + + group('createMap', () { + test('should create on server when online and authenticated', () async { + final serverMap = MapData( + id: 'server-id', + userId: 'user-1', + name: 'Test Map', + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.createMap( + name: 'Test Map', + description: null, + )).thenAnswer((_) async => serverMap); + when(() => mockStorage.getCachedMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + final result = await syncService.createMap( + name: 'Test Map', + isAuthenticated: true, + ); + + expect(result.id, 'server-id'); + expect(result.isLocal, false); + verify(() => mockRepository.createMap( + name: 'Test Map', + description: null, + )).called(1); + }); + + test('should create locally when offline', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalMaps(any())) + .thenAnswer((_) async {}); + + final result = await syncService.createMap( + name: 'Test Map', + isAuthenticated: true, + ); + + expect(result.isLocal, true); + expect(result.name, 'Test Map'); + verifyNever(() => mockRepository.createMap( + name: any(named: 'name'), + description: any(named: 'description'), + )); + }); + + test('should create locally when not authenticated', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalMaps(any())) + .thenAnswer((_) async {}); + + final result = await syncService.createMap( + name: 'Test Map', + isAuthenticated: false, + ); + + expect(result.isLocal, true); + }); + }); + + group('deleteMap', () { + test('should delete from local storage when local map', () async { + final localMap = MapData( + id: 'local-1', + userId: null, + name: 'Local Map', + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ); + + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => [localMap]); + when(() => mockStorage.setLocalMaps(any())) + .thenAnswer((_) async {}); + + await syncService.deleteMap( + map: localMap, + isAuthenticated: false, + ); + + verify(() => mockStorage.setLocalMaps([])).called(1); + verifyNever(() => mockRepository.deleteMap(any())); + }); + + test('should delete from server when online and server map', () async { + final serverMap = MapData( + id: 'server-1', + userId: 'user-1', + name: 'Server Map', + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.deleteMap('server-1')) + .thenAnswer((_) async {}); + when(() => mockStorage.getCachedMaps()) + .thenAnswer((_) async => [serverMap]); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + await syncService.deleteMap( + map: serverMap, + isAuthenticated: true, + ); + + verify(() => mockRepository.deleteMap('server-1')).called(1); + }); + + test('should add to pending deletions when offline and server map', + () async { + final serverMap = MapData( + id: 'server-1', + userId: 'user-1', + name: 'Server Map', + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getCachedMaps()) + .thenAnswer((_) async => [serverMap]); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + + await syncService.deleteMap( + map: serverMap, + isAuthenticated: true, + ); + + verify(() => mockStorage.setPendingDeletions(['server-1'])).called(1); + verifyNever(() => mockRepository.deleteMap(any())); + }); + }); + + group('syncWithServer', () { + test('should not sync when offline', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + + final result = await syncService.syncWithServer(); + + expect(result, isEmpty); + verifyNever(() => mockRepository.getMaps()); + }); + + test('should process pending deletions', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => ['del-1', 'del-2']); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockRepository.deleteMap(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => []); + when(() => mockRepository.getMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + await syncService.syncWithServer(); + + verify(() => mockRepository.deleteMap('del-1')).called(1); + verify(() => mockRepository.deleteMap('del-2')).called(1); + verify(() => mockStorage.setPendingDeletions([])).called(1); + }); + + test('should upload local maps and return ID mapping', () async { + final localMap = MapData( + id: 'local-uuid', + userId: null, + name: 'My Map', + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ); + final serverMap = MapData( + id: 'server-uuid', + userId: 'user-1', + name: 'My Map', + createdAt: DateTime.utc(2024, 1, 15), + ); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => [localMap]); + when(() => mockRepository.uploadLocalMaps(any())) + .thenAnswer((_) async => {'local-uuid': 'server-uuid'}); + when(() => mockStorage.setLocalMaps(any())) + .thenAnswer((_) async {}); + when(() => mockRepository.getMaps()) + .thenAnswer((_) async => [serverMap]); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + final idMapping = await syncService.syncWithServer(); + + expect(idMapping, {'local-uuid': 'server-uuid'}); + verify(() => mockStorage.setLocalMaps([])).called(1); + }); + + test('should return empty mapping when no local maps', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => []); + when(() => mockRepository.getMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + final idMapping = await syncService.syncWithServer(); + + expect(idMapping, isEmpty); + }); + + test('should handle pending deletion errors gracefully', () async { + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => ['del-1']); + when(() => mockRepository.deleteMap('del-1')) + .thenThrow(Exception('Server error')); + when(() => mockStorage.setPendingDeletions(['del-1'])) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => []); + when(() => mockRepository.getMaps()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedMaps(any())) + .thenAnswer((_) async {}); + + await syncService.syncWithServer(); + + verify(() => mockStorage.setPendingDeletions(['del-1'])).called(1); + }); + + test('should not duplicate maps after sync (local map uploaded)', () async { + final localMap = MapData( + id: 'local-uuid', + userId: null, + name: 'My Map', + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ); + final serverMap = MapData( + id: 'server-uuid', + userId: 'user-1', + name: 'My Map', + createdAt: DateTime.utc(2024, 1, 15), + ); + + // Before sync: local storage has the local map, cache is empty + var currentLocalMaps = [localMap]; + var currentCachedMaps = []; + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalMaps()) + .thenAnswer((_) async => currentLocalMaps); + when(() => mockStorage.setLocalMaps(any())).thenAnswer((inv) async { + currentLocalMaps = + inv.positionalArguments[0] as List; + }); + when(() => mockStorage.getCachedMaps()) + .thenAnswer((_) async => currentCachedMaps); + when(() => mockStorage.setCachedMaps(any())).thenAnswer((inv) async { + currentCachedMaps = + inv.positionalArguments[0] as List; + }); + when(() => mockRepository.uploadLocalMaps(any())) + .thenAnswer((_) async => {'local-uuid': 'server-uuid'}); + when(() => mockRepository.getMaps()) + .thenAnswer((_) async => [serverMap]); + + await syncService.syncWithServer(); + + // After sync, getAllMaps should return exactly 1 map (no duplication) + final allMaps = await syncService.getAllMaps(); + expect(allMaps.length, 1); + expect(allMaps.first.id, 'server-uuid'); + }); + }); + + group('clearIfUserChanged', () { + test('should clear when user changed', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => 'user-1'); + when(() => mockStorage.clearAll()).thenAnswer((_) async {}); + when(() => mockStorage.setLastUserId('user-2')) + .thenAnswer((_) async {}); + + await syncService.clearIfUserChanged('user-2'); + + verify(() => mockStorage.clearAll()).called(1); + }); + + test('should not clear when user is the same', () async { + when(() => mockStorage.getLastUserId()) + .thenAnswer((_) async => 'user-1'); + when(() => mockStorage.setLastUserId('user-1')) + .thenAnswer((_) async {}); + + await syncService.clearIfUserChanged('user-1'); + + verifyNever(() => mockStorage.clearAll()); + }); + }); + }); +} diff --git a/test/features/map/services/pin_sync_service_test.dart b/test/features/map/services/pin_sync_service_test.dart index 849f322..8202355 100644 --- a/test/features/map/services/pin_sync_service_test.dart +++ b/test/features/map/services/pin_sync_service_test.dart @@ -419,6 +419,52 @@ void main() { }); }); + group('remapLocalMapIds', () { + test('should update mapIds of local pins matching the mapping', () async { + final localPins = [ + PinData( + id: 'pin-1', + userId: null, + mapId: 'local-map-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + ), + PinData( + id: 'pin-2', + userId: null, + mapId: 'local-map-2', + position: const LatLng(35.6895, 139.6917), + createdAt: DateTime.utc(2024, 1, 16), + isLocal: true, + ), + ]; + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => localPins); + when(() => mockStorage.setLocalPins(any())) + .thenAnswer((_) async {}); + + await syncService.remapLocalMapIds({ + 'local-map-1': 'server-map-1', + }); + + final captured = + verify(() => mockStorage.setLocalPins(captureAny())).captured; + final updatedPins = captured.last as List; + + expect(updatedPins[0].mapId, 'server-map-1'); + expect(updatedPins[1].mapId, 'local-map-2'); + }); + + test('should do nothing when mapping is empty', () async { + await syncService.remapLocalMapIds({}); + + verifyNever(() => mockStorage.getLocalPins()); + verifyNever(() => mockStorage.setLocalPins(any())); + }); + }); + group('clearIfUserChanged', () { test('should clear local data when user signs out', () async { when(() => mockStorage.getLastUserId()) diff --git a/web/index.html b/web/index.html index b33902b..77227d6 100644 --- a/web/index.html +++ b/web/index.html @@ -36,6 +36,18 @@ - + From d445b8ae7861c05807e43e5ad9545ad299222be5 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Mon, 16 Mar 2026 16:44:31 +0900 Subject: [PATCH 5/5] fix: improve eraser performance --- .../presentation/widgets/drawing_canvas.dart | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/lib/features/map/presentation/widgets/drawing_canvas.dart b/lib/features/map/presentation/widgets/drawing_canvas.dart index 660c110..42b9b0d 100644 --- a/lib/features/map/presentation/widgets/drawing_canvas.dart +++ b/lib/features/map/presentation/widgets/drawing_canvas.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:latlong2/latlong.dart' hide Path; +import 'package:latlong2/latlong.dart' show LatLng; import 'package:memomap/features/map/models/drawing_path.dart'; import 'package:memomap/features/map/providers/drawing_provider.dart'; @@ -18,21 +18,34 @@ class _DrawingCanvasState extends ConsumerState { DrawingPath? _currentPath; Offset? _eraserPosition; + /// Returns the pixel distance from [pOff] to the line segment [aOff]-[bOff]. + double _pixelDistToSegment(Offset pOff, Offset aOff, Offset bOff) { + final dx = bOff.dx - aOff.dx; + final dy = bOff.dy - aOff.dy; + final lenSq = dx * dx + dy * dy; + if (lenSq == 0) { + final ex = pOff.dx - aOff.dx; + final ey = pOff.dy - aOff.dy; + return math.sqrt(ex * ex + ey * ey); + } + + final t = (((pOff.dx - aOff.dx) * dx + (pOff.dy - aOff.dy) * dy) / lenSq) + .clamp(0.0, 1.0); + final projX = aOff.dx + t * dx; + final projY = aOff.dy + t * dy; + final ex = pOff.dx - projX; + final ey = pOff.dy - projY; + return math.sqrt(ex * ex + ey * ey); + } + void _handleEraser(Offset localPosition) { final drawingState = ref.read(drawingProvider).valueOrNull; if (drawingState == null) return; final drawingNotifier = ref.read(drawingProvider.notifier); - final latLng = widget.mapController.camera.screenOffsetToLatLng( - localPosition, - ); - final distance = const Distance(); - - final metersPerPixel = - 156543.03392 * - math.cos(latLng.latitude * math.pi / 180) / - math.pow(2, widget.mapController.camera.zoom); - final eraserRadius = drawingState.strokeWidth * metersPerPixel * 2; + final camera = widget.mapController.camera; + final eraserOff = localPosition; + final eraserRadius = drawingState.strokeWidth * 2; List newPaths = []; bool changed = false; @@ -41,8 +54,15 @@ class _DrawingCanvasState extends ConsumerState { List currentSegment = []; bool pathModified = false; - for (final point in path.points) { - if (distance(latLng, point) < eraserRadius) { + var prevOff = camera.latLngToScreenOffset(path.points.first); + for (int i = 0; i < path.points.length; i++) { + final pointOff = i == 0 + ? prevOff + : camera.latLngToScreenOffset(path.points[i]); + final dist = _pixelDistToSegment(eraserOff, prevOff, pointOff); + prevOff = pointOff; + + if (dist < eraserRadius) { if (currentSegment.length > 1) { newPaths.add( DrawingPath( @@ -56,7 +76,7 @@ class _DrawingCanvasState extends ConsumerState { pathModified = true; changed = true; } else { - currentSegment.add(point); + currentSegment.add(path.points[i]); } } @@ -68,8 +88,6 @@ class _DrawingCanvasState extends ConsumerState { strokeWidth: path.strokeWidth, ), ); - } else if (pathModified && currentSegment.length <= 1) { - // Segment too short after modification, skip } else if (!pathModified) { newPaths.add(path); }