diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5aee809..e6e7acd 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -97,6 +97,7 @@ export const pins = pgTable("pins", { mapId: uuid("map_id").references(() => maps.id, { onDelete: "cascade" }), latitude: doublePrecision("latitude").notNull(), longitude: doublePrecision("longitude").notNull(), + memo: text("memo"), createdAt: timestamp("created_at").defaultNow().notNull(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0ee2892..fd83c89 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,7 @@ import { HealthSchema, PinSchema, PinsArraySchema, + UpdatePinSchema, UserSchema, } from "./schemas/pin"; @@ -259,9 +260,7 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); - if ( - !(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId)) - ) { + if (!(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId))) { return c.json({ error: "Map not found" }, 404); } @@ -274,6 +273,7 @@ app.post( mapId: body.mapId ?? null, latitude: body.latitude, longitude: body.longitude, + memo: body.memo ?? null, }) .returning(), ); @@ -286,6 +286,75 @@ app.post( }, ); +app.put( + "/api/pins/:id", + describeRoute({ + tags: ["pins"], + summary: "Update a pin", + responses: { + 200: { + description: "Pin updated", + content: { "application/json": { schema: resolver(PinSchema) } }, + }, + 400: { + description: "Invalid request", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 404: { + description: "Pin not found", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: resolver(ErrorSchema) } }, + }, + }, + }), + authMiddleware, + validator("json", UpdatePinSchema), + async (c) => { + const userId = c.get("userId"); + const pinId = c.req.param("id"); + const body = c.req.valid("json"); + + if (!pinId || !/^[0-9a-f-]{36}$/i.test(pinId)) { + return c.json({ error: "Invalid pin ID" }, 400); + } + + try { + const updateData: { + memo?: string | null; + } = {}; + if (body.memo !== undefined) updateData.memo = body.memo; + + 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(pins) + .set(updateData) + .where(and(eq(pins.id, pinId), eq(pins.userId, userId))) + .returning(), + ); + + if (!data) { + return c.json({ error: "Pin not found" }, 404); + } + + return c.json(data); + } catch (error) { + console.error("Failed to update pin:", error); + return c.json({ error: "Failed to update pin" }, 500); + } + }, +); + app.delete( "/api/pins/:id", describeRoute({ @@ -373,6 +442,7 @@ app.post( mapId: pin.mapId ?? null, latitude: pin.latitude, longitude: pin.longitude, + memo: pin.memo ?? null, })); try { @@ -463,9 +533,7 @@ app.post( const userId = c.get("userId"); const body = c.req.valid("json"); - if ( - !(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId)) - ) { + if (!(await validateMapOwnership(c.env.DATABASE_URL, body.mapId, userId))) { return c.json({ error: "Map not found" }, 404); } diff --git a/backend/src/schemas/pin.ts b/backend/src/schemas/pin.ts index 70a80bf..6c079d8 100644 --- a/backend/src/schemas/pin.ts +++ b/backend/src/schemas/pin.ts @@ -16,6 +16,7 @@ export const CreatePinSchema = v.object({ latitude: LatitudeSchema, longitude: LongitudeSchema, mapId: v.optional(v.nullable(v.string())), + memo: v.optional(v.nullable(v.string())), }); export const BatchCreatePinsSchema = v.object({ @@ -25,12 +26,17 @@ export const BatchCreatePinsSchema = v.object({ latitude: LatitudeSchema, longitude: LongitudeSchema, mapId: v.optional(v.nullable(v.string())), + memo: v.optional(v.nullable(v.string())), }), ), v.maxLength(100), ), }); +export const UpdatePinSchema = v.object({ + memo: v.optional(v.nullable(v.string())), +}); + export const PinSchema = v.object({ id: v.string(), userId: v.string(), @@ -38,6 +44,7 @@ export const PinSchema = v.object({ latitude: v.number(), longitude: v.number(), createdAt: v.string(), + memo: v.nullable(v.string()), }); export const PinsArraySchema = v.array(PinSchema); diff --git a/doc b/doc index a9f0b01..f0e7408 100644 --- a/doc +++ b/doc @@ -137,6 +137,16 @@ }, "createdAt": { "type": "string" + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -145,7 +155,8 @@ "mapId", "latitude", "longitude", - "createdAt" + "createdAt", + "memo" ] } } @@ -228,6 +239,16 @@ }, "createdAt": { "type": "string" + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -236,7 +257,8 @@ "mapId", "latitude", "longitude", - "createdAt" + "createdAt", + "memo" ] } } @@ -322,6 +344,16 @@ "type": "null" } ] + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -335,6 +367,175 @@ } }, "/api/pins/{id}": { + "put": { + "operationId": "putApiPinsById", + "tags": [ + "pins" + ], + "summary": "Update a pin", + "responses": { + "200": { + "description": "Pin updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "mapId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "userId", + "mapId", + "latitude", + "longitude", + "createdAt", + "memo" + ] + } + } + } + }, + "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": "Pin 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": { + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ] + }, "delete": { "operationId": "deleteApiPinsById", "tags": [ @@ -453,6 +654,16 @@ }, "createdAt": { "type": "string" + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -461,7 +672,8 @@ "mapId", "latitude", "longitude", - "createdAt" + "createdAt", + "memo" ] } } @@ -553,6 +765,16 @@ "type": "null" } ] + }, + "memo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000..da8edf5 Binary files /dev/null and b/flutter_01.png differ diff --git a/lib/api/clients/pins_client.dart b/lib/api/clients/pins_client.dart index fa73f31..150d078 100644 --- a/lib/api/clients/pins_client.dart +++ b/lib/api/clients/pins_client.dart @@ -6,10 +6,12 @@ import 'package:dio/dio.dart'; import 'package:retrofit/retrofit.dart'; import '../models/api_pins_batch_request_body.dart'; +import '../models/api_pins_id_request_body.dart'; import '../models/api_pins_request_body.dart'; import '../models/get_api_pins_response.dart'; import '../models/post_api_pins_batch_response.dart'; import '../models/post_api_pins_response.dart'; +import '../models/put_api_pins_id_response.dart'; part 'pins_client.g.dart'; @@ -27,6 +29,13 @@ abstract class PinsClient { @Body() ApiPinsRequestBody? body, }); + /// Update a pin + @PUT('/api/pins/{id}') + Future putApiPinsById({ + @Path('id') required String id, + @Body() ApiPinsIdRequestBody? body, + }); + /// Delete a pin @DELETE('/api/pins/{id}') Future deleteApiPinsById({ diff --git a/lib/api/clients/pins_client.g.dart b/lib/api/clients/pins_client.g.dart index 77b1b9d..80f7b6a 100644 --- a/lib/api/clients/pins_client.g.dart +++ b/lib/api/clients/pins_client.g.dart @@ -80,6 +80,38 @@ class _PinsClient implements PinsClient { return _value; } + @override + Future putApiPinsById({ + required String id, + ApiPinsIdRequestBody? 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/pins/${id}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late PutApiPinsIdResponse _value; + try { + _value = PutApiPinsIdResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, response: _result); + rethrow; + } + return _value; + } + @override Future deleteApiPinsById({required String id}) async { final _extra = {}; diff --git a/lib/api/export.dart b/lib/api/export.dart index 84a455e..56b0034 100644 --- a/lib/api/export.dart +++ b/lib/api/export.dart @@ -14,6 +14,8 @@ export 'models/get_api_me_response.dart'; export 'models/get_api_pins_response.dart'; export 'models/post_api_pins_response.dart'; export 'models/api_pins_request_body.dart'; +export 'models/put_api_pins_id_response.dart'; +export 'models/api_pins_id_request_body.dart'; export 'models/post_api_pins_batch_response.dart'; export 'models/pins.dart'; export 'models/api_pins_batch_request_body.dart'; diff --git a/lib/api/models/api_pins_id_request_body.dart b/lib/api/models/api_pins_id_request_body.dart new file mode 100644 index 0000000..39a8eae --- /dev/null +++ b/lib/api/models/api_pins_id_request_body.dart @@ -0,0 +1,20 @@ +// 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_pins_id_request_body.g.dart'; + +@JsonSerializable() +class ApiPinsIdRequestBody { + const ApiPinsIdRequestBody({ + this.memo, + }); + + factory ApiPinsIdRequestBody.fromJson(Map json) => _$ApiPinsIdRequestBodyFromJson(json); + + final String? memo; + + Map toJson() => _$ApiPinsIdRequestBodyToJson(this); +} diff --git a/lib/api/models/api_pins_id_request_body.g.dart b/lib/api/models/api_pins_id_request_body.g.dart new file mode 100644 index 0000000..192bdb5 --- /dev/null +++ b/lib/api/models/api_pins_id_request_body.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_pins_id_request_body.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiPinsIdRequestBody _$ApiPinsIdRequestBodyFromJson( + Map json, +) => ApiPinsIdRequestBody(memo: json['memo'] as String?); + +Map _$ApiPinsIdRequestBodyToJson( + ApiPinsIdRequestBody instance, +) => {'memo': instance.memo}; diff --git a/lib/api/models/api_pins_request_body.dart b/lib/api/models/api_pins_request_body.dart index b8fcdc9..467cc62 100644 --- a/lib/api/models/api_pins_request_body.dart +++ b/lib/api/models/api_pins_request_body.dart @@ -12,6 +12,7 @@ class ApiPinsRequestBody { required this.latitude, required this.longitude, this.mapId, + this.memo, }); factory ApiPinsRequestBody.fromJson(Map json) => _$ApiPinsRequestBodyFromJson(json); @@ -19,6 +20,7 @@ class ApiPinsRequestBody { final num latitude; final num longitude; final String? mapId; + final String? memo; 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 b83b501..eded34d 100644 --- a/lib/api/models/api_pins_request_body.g.dart +++ b/lib/api/models/api_pins_request_body.g.dart @@ -11,6 +11,7 @@ ApiPinsRequestBody _$ApiPinsRequestBodyFromJson(Map json) => latitude: json['latitude'] as num, longitude: json['longitude'] as num, mapId: json['mapId'] as String?, + memo: json['memo'] as String?, ); Map _$ApiPinsRequestBodyToJson(ApiPinsRequestBody instance) => @@ -18,4 +19,5 @@ Map _$ApiPinsRequestBodyToJson(ApiPinsRequestBody instance) => 'latitude': instance.latitude, 'longitude': instance.longitude, 'mapId': instance.mapId, + 'memo': instance.memo, }; diff --git a/lib/api/models/get_api_pins_response.dart b/lib/api/models/get_api_pins_response.dart index 2351023..138d044 100644 --- a/lib/api/models/get_api_pins_response.dart +++ b/lib/api/models/get_api_pins_response.dart @@ -15,6 +15,7 @@ class GetApiPinsResponse { required this.latitude, required this.longitude, required this.createdAt, + required this.memo, }); factory GetApiPinsResponse.fromJson(Map json) => _$GetApiPinsResponseFromJson(json); @@ -25,6 +26,7 @@ class GetApiPinsResponse { final num latitude; final num longitude; final String createdAt; + final String? memo; Map toJson() => _$GetApiPinsResponseToJson(this); } diff --git a/lib/api/models/get_api_pins_response.g.dart b/lib/api/models/get_api_pins_response.g.dart index dcb70cb..48f712e 100644 --- a/lib/api/models/get_api_pins_response.g.dart +++ b/lib/api/models/get_api_pins_response.g.dart @@ -14,6 +14,7 @@ GetApiPinsResponse _$GetApiPinsResponseFromJson(Map json) => latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, + memo: json['memo'] as String?, ); Map _$GetApiPinsResponseToJson(GetApiPinsResponse instance) => @@ -24,4 +25,5 @@ Map _$GetApiPinsResponseToJson(GetApiPinsResponse instance) => 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, + 'memo': instance.memo, }; diff --git a/lib/api/models/pins.dart b/lib/api/models/pins.dart index 9dd2162..5c74ef3 100644 --- a/lib/api/models/pins.dart +++ b/lib/api/models/pins.dart @@ -12,6 +12,7 @@ class Pins { required this.latitude, required this.longitude, this.mapId, + this.memo, }); factory Pins.fromJson(Map json) => _$PinsFromJson(json); @@ -19,6 +20,7 @@ class Pins { final num latitude; final num longitude; final String? mapId; + final String? memo; Map toJson() => _$PinsToJson(this); } diff --git a/lib/api/models/pins.g.dart b/lib/api/models/pins.g.dart index d583ddd..954bc56 100644 --- a/lib/api/models/pins.g.dart +++ b/lib/api/models/pins.g.dart @@ -10,10 +10,12 @@ Pins _$PinsFromJson(Map json) => Pins( latitude: json['latitude'] as num, longitude: json['longitude'] as num, mapId: json['mapId'] as String?, + memo: json['memo'] as String?, ); Map _$PinsToJson(Pins instance) => { 'latitude': instance.latitude, 'longitude': instance.longitude, 'mapId': instance.mapId, + 'memo': instance.memo, }; diff --git a/lib/api/models/post_api_pins_batch_response.dart b/lib/api/models/post_api_pins_batch_response.dart index c83fa95..6cce4f8 100644 --- a/lib/api/models/post_api_pins_batch_response.dart +++ b/lib/api/models/post_api_pins_batch_response.dart @@ -15,6 +15,7 @@ class PostApiPinsBatchResponse { required this.latitude, required this.longitude, required this.createdAt, + required this.memo, }); factory PostApiPinsBatchResponse.fromJson(Map json) => _$PostApiPinsBatchResponseFromJson(json); @@ -25,6 +26,7 @@ class PostApiPinsBatchResponse { final num latitude; final num longitude; final String createdAt; + final String? memo; Map toJson() => _$PostApiPinsBatchResponseToJson(this); } 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 187ec2f..816ed56 100644 --- a/lib/api/models/post_api_pins_batch_response.g.dart +++ b/lib/api/models/post_api_pins_batch_response.g.dart @@ -15,6 +15,7 @@ PostApiPinsBatchResponse _$PostApiPinsBatchResponseFromJson( latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, + memo: json['memo'] as String?, ); Map _$PostApiPinsBatchResponseToJson( @@ -26,4 +27,5 @@ Map _$PostApiPinsBatchResponseToJson( 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, + 'memo': instance.memo, }; diff --git a/lib/api/models/post_api_pins_response.dart b/lib/api/models/post_api_pins_response.dart index 39cd32d..d86d587 100644 --- a/lib/api/models/post_api_pins_response.dart +++ b/lib/api/models/post_api_pins_response.dart @@ -15,6 +15,7 @@ class PostApiPinsResponse { required this.latitude, required this.longitude, required this.createdAt, + required this.memo, }); factory PostApiPinsResponse.fromJson(Map json) => _$PostApiPinsResponseFromJson(json); @@ -25,6 +26,7 @@ class PostApiPinsResponse { final num latitude; final num longitude; final String createdAt; + final String? memo; Map toJson() => _$PostApiPinsResponseToJson(this); } diff --git a/lib/api/models/post_api_pins_response.g.dart b/lib/api/models/post_api_pins_response.g.dart index 0b3f875..b7a85eb 100644 --- a/lib/api/models/post_api_pins_response.g.dart +++ b/lib/api/models/post_api_pins_response.g.dart @@ -14,6 +14,7 @@ PostApiPinsResponse _$PostApiPinsResponseFromJson(Map json) => latitude: json['latitude'] as num, longitude: json['longitude'] as num, createdAt: json['createdAt'] as String, + memo: json['memo'] as String?, ); Map _$PostApiPinsResponseToJson( @@ -25,4 +26,5 @@ Map _$PostApiPinsResponseToJson( 'latitude': instance.latitude, 'longitude': instance.longitude, 'createdAt': instance.createdAt, + 'memo': instance.memo, }; diff --git a/lib/api/models/put_api_pins_id_response.dart b/lib/api/models/put_api_pins_id_response.dart new file mode 100644 index 0000000..5f130b4 --- /dev/null +++ b/lib/api/models/put_api_pins_id_response.dart @@ -0,0 +1,32 @@ +// 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_pins_id_response.g.dart'; + +@JsonSerializable() +class PutApiPinsIdResponse { + const PutApiPinsIdResponse({ + required this.id, + required this.userId, + required this.mapId, + required this.latitude, + required this.longitude, + required this.createdAt, + required this.memo, + }); + + factory PutApiPinsIdResponse.fromJson(Map json) => _$PutApiPinsIdResponseFromJson(json); + + final String id; + final String userId; + final String? mapId; + final num latitude; + final num longitude; + final String createdAt; + final String? memo; + + Map toJson() => _$PutApiPinsIdResponseToJson(this); +} diff --git a/lib/api/models/put_api_pins_id_response.g.dart b/lib/api/models/put_api_pins_id_response.g.dart new file mode 100644 index 0000000..fa65a20 --- /dev/null +++ b/lib/api/models/put_api_pins_id_response.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'put_api_pins_id_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PutApiPinsIdResponse _$PutApiPinsIdResponseFromJson( + Map json, +) => PutApiPinsIdResponse( + 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, + memo: json['memo'] as String?, +); + +Map _$PutApiPinsIdResponseToJson( + PutApiPinsIdResponse instance, +) => { + 'id': instance.id, + 'userId': instance.userId, + 'mapId': instance.mapId, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'createdAt': instance.createdAt, + 'memo': instance.memo, +}; diff --git a/lib/features/map/data/local_pin_storage.dart b/lib/features/map/data/local_pin_storage.dart index 3e19446..711d778 100644 --- a/lib/features/map/data/local_pin_storage.dart +++ b/lib/features/map/data/local_pin_storage.dart @@ -13,6 +13,9 @@ abstract interface class LocalPinStorageBase { Future> getPendingDeletions(); Future setPendingDeletions(List ids); + Future> getPendingMemoUpdates(); + Future setPendingMemoUpdates(Map updates); + Future getLastUserId(); Future setLastUserId(String? userId); @@ -23,6 +26,7 @@ class SharedPreferencesLocalPinStorage implements LocalPinStorageBase { static const _cachedPinsKey = 'memomap_cached_pins'; static const _localPinsKey = 'memomap_local_pins'; static const _pendingDeletionsKey = 'memomap_pending_deletions'; + static const _pendingMemoUpdatesKey = 'memomap_pending_memo_updates'; static const _lastUserIdKey = 'memomap_last_user_id'; final SharedPreferencesAsync _prefs; @@ -69,6 +73,20 @@ class SharedPreferencesLocalPinStorage implements LocalPinStorageBase { await _prefs.setString(_pendingDeletionsKey, jsonString); } + @override + Future> getPendingMemoUpdates() async { + final jsonString = await _prefs.getString(_pendingMemoUpdatesKey); + if (jsonString == null) return {}; + final map = jsonDecode(jsonString) as Map; + return map.map((key, value) => MapEntry(key, value as String?)); + } + + @override + Future setPendingMemoUpdates(Map updates) async { + final jsonString = jsonEncode(updates); + await _prefs.setString(_pendingMemoUpdatesKey, jsonString); + } + @override Future getLastUserId() async { return _prefs.getString(_lastUserIdKey); @@ -89,6 +107,7 @@ class SharedPreferencesLocalPinStorage implements LocalPinStorageBase { _prefs.remove(_cachedPinsKey), _prefs.remove(_localPinsKey), _prefs.remove(_pendingDeletionsKey), + _prefs.remove(_pendingMemoUpdatesKey), ]); } diff --git a/lib/features/map/data/pin_repository.dart b/lib/features/map/data/pin_repository.dart index 4024dd2..2c36b1c 100644 --- a/lib/features/map/data/pin_repository.dart +++ b/lib/features/map/data/pin_repository.dart @@ -1,11 +1,13 @@ import 'package:latlong2/latlong.dart'; import 'package:memomap/api/api_client.dart'; import 'package:memomap/api/models/api_pins_batch_request_body.dart'; +import 'package:memomap/api/models/api_pins_id_request_body.dart'; import 'package:memomap/api/models/api_pins_request_body.dart'; import 'package:memomap/api/models/get_api_pins_response.dart'; import 'package:memomap/api/models/pins.dart'; import 'package:memomap/api/models/post_api_pins_batch_response.dart'; import 'package:memomap/api/models/post_api_pins_response.dart'; +import 'package:memomap/api/models/put_api_pins_id_response.dart'; import 'package:memomap/config/backend_config.dart'; import 'package:memomap/features/auth/data/token_storage.dart'; import 'package:memomap/features/map/data/pin_repository_base.dart'; @@ -37,6 +39,7 @@ extension GetApiPinsResponseExt on GetApiPinsResponse { latitude: latitude, longitude: longitude, createdAt: createdAt, + memo: memo, ); } @@ -48,6 +51,7 @@ extension PostApiPinsResponseExt on PostApiPinsResponse { latitude: latitude, longitude: longitude, createdAt: createdAt, + memo: memo, ); } @@ -59,9 +63,24 @@ extension PostApiPinsBatchResponseExt on PostApiPinsBatchResponse { latitude: latitude, longitude: longitude, createdAt: createdAt, + memo: memo, ); } +extension PutApiPinsIdResponseExt on PutApiPinsIdResponse { + PinData toPinData() => _createPinData( + id: id, + userId: userId, + mapId: mapId, + latitude: latitude, + longitude: longitude, + createdAt: createdAt, + memo: memo, + ); +} + +const _sentinel = Object(); + class PinData { final String id; final String? userId; @@ -81,7 +100,7 @@ class PinData { this.memo, }); - factory PinData.local(LatLng position, {String? mapId}) { + factory PinData.local(LatLng position, {String? mapId, String? memo}) { return PinData( id: const Uuid().v4(), userId: null, @@ -89,7 +108,27 @@ class PinData { position: position, createdAt: DateTime.now(), isLocal: true, - memo: null, + memo: memo, + ); + } + + PinData copyWith({ + String? id, + String? userId, + String? mapId, + LatLng? position, + DateTime? createdAt, + bool? isLocal, + Object? memo = _sentinel, + }) { + return PinData( + id: id ?? this.id, + userId: userId ?? this.userId, + mapId: mapId ?? this.mapId, + position: position ?? this.position, + createdAt: createdAt ?? this.createdAt, + isLocal: isLocal ?? this.isLocal, + memo: identical(memo, _sentinel) ? this.memo : memo as String?, ); } @@ -150,7 +189,7 @@ class PinRepository implements PinRepositoryBase { } @override - Future addPin(LatLng position, {String? mapId}) async { + Future addPin(LatLng position, {String? mapId, String? memo}) async { if (!await _isAuthenticated()) return null; final response = await _api.pins.postApiPins( @@ -158,6 +197,26 @@ class PinRepository implements PinRepositoryBase { latitude: position.latitude, longitude: position.longitude, mapId: mapId, + memo: memo, + ), + ); + + return response.toPinData(); + } + + @override + Future updatePin( + String id, { + LatLng? position, + String? mapId, + String? memo, + }) async { + if (!await _isAuthenticated()) return null; + + final response = await _api.pins.putApiPinsById( + id: id, + body: ApiPinsIdRequestBody( + memo: memo, ), ); @@ -182,6 +241,7 @@ class PinRepository implements PinRepositoryBase { latitude: pin.position.latitude, longitude: pin.position.longitude, mapId: pin.mapId ?? mapId, + memo: pin.memo, )) .toList(), ), diff --git a/lib/features/map/data/pin_repository_base.dart b/lib/features/map/data/pin_repository_base.dart index a267e1d..8f846bf 100644 --- a/lib/features/map/data/pin_repository_base.dart +++ b/lib/features/map/data/pin_repository_base.dart @@ -3,7 +3,13 @@ import 'package:memomap/features/map/data/pin_repository.dart'; abstract interface class PinRepositoryBase { Future> getPins(); - Future addPin(LatLng position, {String? mapId}); + Future addPin(LatLng position, {String? mapId, String? memo}); + Future updatePin( + String id, { + LatLng? position, + String? mapId, + String? memo, + }); Future deletePin(String id); Future> uploadLocalPins(List localPins, {String? mapId}); } diff --git a/lib/features/map/presentation/pin_memo_screen.dart b/lib/features/map/presentation/pin_memo_screen.dart index 12565ba..198668a 100644 --- a/lib/features/map/presentation/pin_memo_screen.dart +++ b/lib/features/map/presentation/pin_memo_screen.dart @@ -80,7 +80,9 @@ class _PinMemoScreenState extends ConsumerState { .updatePinMemo(widget.pinId, text.isEmpty ? null : text); if (!mounted) return; setState(() => _isSaving = false); - context.pop(); + if (context.mounted) { + context.pop(); + } }, child: _isSaving ? const SizedBox( @@ -103,7 +105,9 @@ class _PinMemoScreenState extends ConsumerState { .updatePinMemo(widget.pinId, null); if (!mounted) return; setState(() => _isSaving = false); - context.pop(); + if (context.mounted) { + context.pop(); + } }, child: const Text('削除'), ), diff --git a/lib/features/map/providers/pin_provider.dart b/lib/features/map/providers/pin_provider.dart index 3abe531..1dd9866 100644 --- a/lib/features/map/providers/pin_provider.dart +++ b/lib/features/map/providers/pin_provider.dart @@ -102,13 +102,13 @@ class PinsNotifier extends AsyncNotifier> { } } - Future addPin(LatLng position) async { + Future addPin(LatLng position, {String? memo}) 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, mapId: mapId); + final optimisticPin = PinData.local(position, mapId: mapId, memo: memo); state = AsyncValue.data([optimisticPin, ...previous]); try { @@ -116,6 +116,7 @@ class PinsNotifier extends AsyncNotifier> { position: position, isAuthenticated: isAuthenticated, mapId: mapId, + memo: memo, ); state = AsyncValue.data( @@ -161,23 +162,20 @@ class PinsNotifier extends AsyncNotifier> { state = AsyncValue.data( current.map((p) { if (p.id == id) { - return PinData( - id: p.id, - userId: p.userId, - mapId: p.mapId, - position: p.position, - createdAt: p.createdAt, - isLocal: p.isLocal, - memo: memo, - ); + return p.copyWith(memo: memo); } return p; }).toList(), ); try { + final isAuthenticated = ref.read(isAuthenticatedProvider); final syncService = await ref.read(pinSyncServiceProvider.future); - await syncService.updatePinMemo(pinId: id, memo: memo); + await syncService.updatePinMemo( + pinId: id, + memo: memo, + isAuthenticated: isAuthenticated, + ); } catch (e, st) { if (kDebugMode) { debugPrint('Failed to persist memo: $e\n$st'); diff --git a/lib/features/map/services/pin_sync_service.dart b/lib/features/map/services/pin_sync_service.dart index 9a152f6..b63fd5d 100644 --- a/lib/features/map/services/pin_sync_service.dart +++ b/lib/features/map/services/pin_sync_service.dart @@ -26,34 +26,40 @@ class PinSyncService { required LatLng position, required bool isAuthenticated, String? mapId, + String? memo, }) async { if (!isAuthenticated) { - return _addLocalPin(position, mapId: mapId); + return _addLocalPin(position, mapId: mapId, memo: memo); } final isOnline = await networkChecker.isOnline; if (!isOnline) { - return _addLocalPin(position, mapId: mapId); + return _addLocalPin(position, mapId: mapId, memo: memo); } try { - final serverPin = await repository.addPin(position, mapId: mapId); + final serverPin = + await repository.addPin(position, mapId: mapId, memo: memo); if (serverPin != null) { final cachedPins = await storage.getCachedPins(); await storage.setCachedPins([serverPin, ...cachedPins]); return serverPin; } - return _addLocalPin(position, mapId: mapId); + return _addLocalPin(position, mapId: mapId, memo: memo); } catch (e) { if (kDebugMode) { debugPrint('Failed to add pin to server: $e'); } - return _addLocalPin(position, mapId: mapId); + return _addLocalPin(position, mapId: mapId, memo: memo); } } - Future _addLocalPin(LatLng position, {String? mapId}) async { - final localPin = PinData.local(position, mapId: mapId); + Future _addLocalPin( + LatLng position, { + String? mapId, + String? memo, + }) async { + final localPin = PinData.local(position, mapId: mapId, memo: memo); final localPins = await storage.getLocalPins(); await storage.setLocalPins([localPin, ...localPins]); return localPin; @@ -63,6 +69,13 @@ class PinSyncService { required PinData pin, required bool isAuthenticated, }) async { + final pendingUpdates = await storage.getPendingMemoUpdates(); + if (pendingUpdates.containsKey(pin.id)) { + final newUpdates = + Map.from(pendingUpdates)..remove(pin.id); + await storage.setPendingMemoUpdates(newUpdates); + } + if (pin.isLocal) { final localPins = await storage.getLocalPins(); await storage.setLocalPins( @@ -107,14 +120,7 @@ class PinSyncService { 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.copyWith(mapId: idMapping[pin.mapId]); } return pin; }).toList(); @@ -126,6 +132,7 @@ class PinSyncService { if (!isOnline) return; await _processPendingDeletions(); + await _processPendingMemoUpdates(); await _uploadLocalPins(); await _refreshCacheFromServer(); } @@ -150,6 +157,28 @@ class PinSyncService { await storage.setPendingDeletions(failedDeletions); } + Future _processPendingMemoUpdates() async { + final pendingUpdates = await storage.getPendingMemoUpdates(); + if (pendingUpdates.isEmpty) return; + + final remainingUpdates = Map.from(pendingUpdates); + + for (final entry in pendingUpdates.entries) { + final pinId = entry.key; + final memo = entry.value; + try { + await repository.updatePin(pinId, memo: memo); + remainingUpdates.remove(pinId); + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to update memo for pin $pinId: $e'); + } + } + } + + await storage.setPendingMemoUpdates(remainingUpdates); + } + Future _uploadLocalPins() async { final localPins = await storage.getLocalPins(); if (localPins.isEmpty) return; @@ -167,7 +196,18 @@ class PinSyncService { Future _refreshCacheFromServer() async { try { final serverPins = await repository.getPins(); - await storage.setCachedPins(serverPins); + final pendingUpdates = await storage.getPendingMemoUpdates(); + if (pendingUpdates.isEmpty) { + await storage.setCachedPins(serverPins); + } else { + final mergedPins = serverPins.map((pin) { + if (pendingUpdates.containsKey(pin.id)) { + return pin.copyWith(memo: pendingUpdates[pin.id]); + } + return pin; + }).toList(); + await storage.setCachedPins(mergedPins); + } } catch (e) { if (kDebugMode) { debugPrint('Failed to refresh cache from server: $e'); @@ -175,55 +215,61 @@ class PinSyncService { } } - /// Update memo for a pin locally. This updates either local pins or cached pins - /// depending on where the pin exists. This is stored only on the client side - /// (no server update is attempted here). - Future updatePinMemo({required String pinId, required String? memo}) async { + /// Update memo for a pin. If the pin is local, updates local storage. + /// If the pin is a server pin and the user is authenticated and online, + /// updates the server and cached storage. If offline or the server call fails, + /// updates cached storage and queues the update to be sent to the server later. + Future updatePinMemo({ + required String pinId, + required String? memo, + required bool isAuthenticated, + }) async { final localPins = await storage.getLocalPins(); - final cachedPins = await storage.getCachedPins(); - - var updated = false; - - final newLocal = localPins.map((pin) { - if (pin.id == pinId) { - updated = true; - return PinData( - id: pin.id, - userId: pin.userId, - mapId: pin.mapId, - position: pin.position, - createdAt: pin.createdAt, - isLocal: pin.isLocal, - memo: memo, - ); - } - return pin; - }).toList(); - - if (updated) { - await storage.setLocalPins(newLocal); + final localIndex = localPins.indexWhere((p) => p.id == pinId); + if (localIndex != -1) { + final updatedLocal = List.from(localPins); + updatedLocal[localIndex] = updatedLocal[localIndex].copyWith(memo: memo); + await storage.setLocalPins(updatedLocal); return; } - final newCached = cachedPins.map((pin) { - if (pin.id == pinId) { - updated = true; - return PinData( - id: pin.id, - userId: pin.userId, - mapId: pin.mapId, - position: pin.position, - createdAt: pin.createdAt, - isLocal: pin.isLocal, - memo: memo, - ); + final isOnline = await networkChecker.isOnline; + if (isAuthenticated && isOnline) { + try { + final serverPin = await repository.updatePin(pinId, memo: memo); + final cachedPins = await storage.getCachedPins(); + final cachedIndex = cachedPins.indexWhere((p) => p.id == pinId); + if (cachedIndex != -1) { + final updatedCached = List.from(cachedPins); + updatedCached[cachedIndex] = + serverPin ?? updatedCached[cachedIndex].copyWith(memo: memo); + await storage.setCachedPins(updatedCached); + } + final pendingUpdates = await storage.getPendingMemoUpdates(); + if (pendingUpdates.containsKey(pinId)) { + final newUpdates = + Map.from(pendingUpdates)..remove(pinId); + await storage.setPendingMemoUpdates(newUpdates); + } + return; + } catch (e) { + if (kDebugMode) { + debugPrint('Failed to update pin memo on server: $e'); + } } - return pin; - }).toList(); + } - if (updated) { - await storage.setCachedPins(newCached); + final cachedPins = await storage.getCachedPins(); + final cachedIndex = cachedPins.indexWhere((p) => p.id == pinId); + if (cachedIndex != -1) { + final updatedCached = List.from(cachedPins); + updatedCached[cachedIndex] = + updatedCached[cachedIndex].copyWith(memo: memo); + await storage.setCachedPins(updatedCached); } + final pendingUpdates = await storage.getPendingMemoUpdates(); + final newUpdates = Map.from(pendingUpdates)..[pinId] = memo; + await storage.setPendingMemoUpdates(newUpdates); } Future clearIfUserChanged(String? currentUserId) async { diff --git a/test/features/map/data/local_pin_storage_test.dart b/test/features/map/data/local_pin_storage_test.dart index e1a244f..7b79d74 100644 --- a/test/features/map/data/local_pin_storage_test.dart +++ b/test/features/map/data/local_pin_storage_test.dart @@ -113,6 +113,31 @@ void main() { }); }); + group('pendingMemoUpdates', () { + test('should return empty map when no pending memo updates', () async { + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + + final updates = await mockStorage.getPendingMemoUpdates(); + expect(updates, isEmpty); + }); + + test('should store and retrieve pending memo updates', () async { + final updates = {'pin-1': 'Test memo', 'pin-2': null}; + + when(() => mockStorage.setPendingMemoUpdates(updates)) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => updates); + + await mockStorage.setPendingMemoUpdates(updates); + final retrieved = await mockStorage.getPendingMemoUpdates(); + + expect(retrieved, updates); + verify(() => mockStorage.setPendingMemoUpdates(updates)).called(1); + }); + }); + group('clearAll', () { test('should clear all stored data', () async { when(() => mockStorage.clearAll()) diff --git a/test/features/map/data/pin_data_test.dart b/test/features/map/data/pin_data_test.dart index 54d8b23..3b401c3 100644 --- a/test/features/map/data/pin_data_test.dart +++ b/test/features/map/data/pin_data_test.dart @@ -156,6 +156,76 @@ void main() { expect(restored.createdAt, original.createdAt); expect(restored.isLocal, original.isLocal); }); + + test('should preserve memo through serialization cycle', () { + final original = PinData( + id: 'memo-round-trip-id', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 7, 20, 18, 30, 0), + isLocal: false, + memo: 'Hello world memo', + ); + + final json = original.toJson(); + final restored = PinData.fromJson(json); + + expect(restored.memo, 'Hello world memo'); + }); + }); + + group('copyWith', () { + test('should copy with updated fields and preserve others', () { + final pin = PinData( + id: 'pin-1', + userId: 'user-1', + mapId: 'map-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Original memo', + ); + + final updated = pin.copyWith( + mapId: 'map-2', + memo: 'Updated memo', + ); + + expect(updated.id, 'pin-1'); + expect(updated.userId, 'user-1'); + expect(updated.mapId, 'map-2'); + expect(updated.memo, 'Updated memo'); + }); + + test('should allow setting memo to null', () { + final pin = PinData( + id: 'pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Original memo', + ); + + final updated = pin.copyWith(memo: null); + + expect(updated.memo, isNull); + }); + + test('should keep original memo when memo argument is omitted', () { + final pin = PinData( + id: 'pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Original memo', + ); + + final updated = pin.copyWith(mapId: 'map-2'); + + expect(updated.memo, 'Original memo'); + }); }); }); } diff --git a/test/features/map/services/pin_sync_service_test.dart b/test/features/map/services/pin_sync_service_test.dart index 8202355..737c23c 100644 --- a/test/features/map/services/pin_sync_service_test.dart +++ b/test/features/map/services/pin_sync_service_test.dart @@ -16,6 +16,7 @@ void main() { registerFallbackValue(const LatLng(0, 0)); registerFallbackValue([]); registerFallbackValue([]); + registerFallbackValue({}); }); setUp(() { @@ -23,6 +24,11 @@ void main() { mockNetworkChecker = MockNetworkChecker(); mockRepository = MockPinRepository(); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + syncService = PinSyncService( storage: mockStorage, networkChecker: mockNetworkChecker, @@ -106,6 +112,63 @@ void main() { verify(() => mockRepository.addPin(position)).called(1); }); + test('should add to server with memo when online and authenticated', () async { + final position = const LatLng(35.6762, 139.6503); + final serverPin = PinData( + id: 'server-id', + userId: 'user-1', + position: position, + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Test memo', + ); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.addPin(position, memo: 'Test memo')) + .thenAnswer((_) async => serverPin); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => []); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + + final result = await syncService.addPin( + position: position, + isAuthenticated: true, + memo: 'Test memo', + ); + + expect(result.id, 'server-id'); + expect(result.memo, 'Test memo'); + expect(result.isLocal, false); + verify(() => mockRepository.addPin(position, memo: 'Test memo')).called(1); + }); + + test('should add to local storage with memo when offline', () async { + final position = const LatLng(35.6762, 139.6503); + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockStorage.setLocalPins(any())) + .thenAnswer((_) async {}); + + final result = await syncService.addPin( + position: position, + isAuthenticated: true, + memo: 'Offline memo', + ); + + expect(result.isLocal, true); + expect(result.memo, 'Offline memo'); + verifyNever(() => mockRepository.addPin(any(), memo: any(named: 'memo'))); + final captured = + verify(() => mockStorage.setLocalPins(captureAny())).captured; + final savedPins = captured.last as List; + expect(savedPins.first.memo, 'Offline memo'); + }); + test('should add to local storage when offline', () async { final position = const LatLng(35.6762, 139.6503); @@ -243,6 +306,38 @@ void main() { verify(() => mockStorage.setPendingDeletions(['server-pin'])).called(1); verifyNever(() => mockRepository.deletePin(any())); }); + + test('should remove from pending memo updates if present when deleting pin', () async { + final pin = PinData( + id: 'pin-with-pending-memo', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + ); + + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {'pin-with-pending-memo': 'Pending memo', 'other-pin': 'Other memo'}); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => [pin]); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.setPendingDeletions(any())) + .thenAnswer((_) async {}); + + await syncService.deletePin( + pin: pin, + isAuthenticated: true, + ); + + verify(() => mockStorage.setPendingMemoUpdates({'other-pin': 'Other memo'})).called(1); + }); }); group('syncWithServer', () { @@ -382,6 +477,95 @@ void main() { // Failed deletion remains in pending list verify(() => mockStorage.setPendingDeletions(['delete-1'])).called(1); }); + + test('should process pending memo updates during sync', () async { + final pendingMemoUpdates = { + 'pin-1': 'Synced memo 1', + 'pin-2': 'Synced memo 2', + }; + final serverPins = [ + PinData( + id: 'pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Synced memo 1', + ), + PinData( + id: 'pin-2', + userId: 'user-1', + position: const LatLng(35.6895, 139.6917), + createdAt: DateTime.utc(2024, 1, 16), + isLocal: false, + memo: 'Synced memo 2', + ), + ]; + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => pendingMemoUpdates); + when(() => mockRepository.updatePin('pin-1', memo: 'Synced memo 1')) + .thenAnswer((_) async => serverPins[0]); + when(() => mockRepository.updatePin('pin-2', memo: 'Synced memo 2')) + .thenAnswer((_) async => serverPins[1]); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockRepository.getPins()) + .thenAnswer((_) async => serverPins); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + + await syncService.syncWithServer(); + + verify(() => mockRepository.updatePin('pin-1', memo: 'Synced memo 1')).called(1); + verify(() => mockRepository.updatePin('pin-2', memo: 'Synced memo 2')).called(1); + verify(() => mockStorage.setPendingMemoUpdates({})).called(1); + }); + + test('should keep failed pending memo updates in queue and preserve them in cache', () async { + final pendingMemoUpdates = {'pin-1': 'Failed memo'}; + final serverPins = [ + PinData( + id: 'pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Old server memo', + ), + ]; + + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockStorage.getPendingDeletions()) + .thenAnswer((_) async => []); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => pendingMemoUpdates); + when(() => mockRepository.updatePin('pin-1', memo: 'Failed memo')) + .thenThrow(Exception('Network error')); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockRepository.getPins()) + .thenAnswer((_) async => serverPins); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + + await syncService.syncWithServer(); + + verify(() => mockStorage.setPendingMemoUpdates({'pin-1': 'Failed memo'})).called(1); + final captured = + verify(() => mockStorage.setCachedPins(captureAny())).captured; + final cachedResult = captured.last as List; + expect(cachedResult.first.memo, 'Failed memo'); + }); }); group('edge cases', () { @@ -463,6 +647,212 @@ void main() { verifyNever(() => mockStorage.getLocalPins()); verifyNever(() => mockStorage.setLocalPins(any())); }); + + test('should preserve memo when remapping mapIds', () 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, + memo: 'Preserve me', + ), + ]; + + 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[0].memo, 'Preserve me'); + }); + }); + + group('updatePinMemo', () { + test('should update local pin in local storage directly', () async { + final localPin = PinData( + id: 'local-pin-1', + userId: null, + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: true, + memo: 'Old memo', + ); + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => [localPin]); + when(() => mockStorage.setLocalPins(any())) + .thenAnswer((_) async {}); + + await syncService.updatePinMemo( + pinId: 'local-pin-1', + memo: 'New memo', + isAuthenticated: false, + ); + + final captured = + verify(() => mockStorage.setLocalPins(captureAny())).captured; + final updatedList = captured.last as List; + expect(updatedList.first.memo, 'New memo'); + verifyNever(() => mockRepository.updatePin(any(), memo: any(named: 'memo'))); + }); + + test('should update server and cached storage when online and authenticated', () async { + final cachedPin = PinData( + id: 'server-pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Old memo', + ); + final updatedServerPin = cachedPin.copyWith(memo: 'New server memo'); + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.updatePin('server-pin-1', memo: 'New server memo')) + .thenAnswer((_) async => updatedServerPin); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => [cachedPin]); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + + await syncService.updatePinMemo( + pinId: 'server-pin-1', + memo: 'New server memo', + isAuthenticated: true, + ); + + verify(() => mockRepository.updatePin('server-pin-1', memo: 'New server memo')).called(1); + final captured = + verify(() => mockStorage.setCachedPins(captureAny())).captured; + final updatedList = captured.last as List; + expect(updatedList.first.memo, 'New server memo'); + verifyNever(() => mockStorage.setPendingMemoUpdates(any())); + }); + + test('should update cached storage and add to pending updates when offline', () async { + final cachedPin = PinData( + id: 'server-pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Old memo', + ); + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => false); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => [cachedPin]); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + + await syncService.updatePinMemo( + pinId: 'server-pin-1', + memo: 'Offline updated memo', + isAuthenticated: true, + ); + + verifyNever(() => mockRepository.updatePin(any(), memo: any(named: 'memo'))); + final capturedPins = + verify(() => mockStorage.setCachedPins(captureAny())).captured; + expect((capturedPins.last as List).first.memo, 'Offline updated memo'); + verify(() => mockStorage.setPendingMemoUpdates({'server-pin-1': 'Offline updated memo'})).called(1); + }); + + test('should update cached storage and add to pending updates when server fails', () async { + final cachedPin = PinData( + id: 'server-pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + ); + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.updatePin('server-pin-1', memo: 'Failed memo')) + .thenThrow(Exception('Server error')); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => [cachedPin]); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + when(() => mockStorage.setPendingMemoUpdates(any())) + .thenAnswer((_) async {}); + + await syncService.updatePinMemo( + pinId: 'server-pin-1', + memo: 'Failed memo', + isAuthenticated: true, + ); + + final capturedPins = + verify(() => mockStorage.setCachedPins(captureAny())).captured; + expect((capturedPins.last as List).first.memo, 'Failed memo'); + verify(() => mockStorage.setPendingMemoUpdates({'server-pin-1': 'Failed memo'})).called(1); + }); + + test('should allow clearing memo (setting memo to null)', () async { + final cachedPin = PinData( + id: 'server-pin-1', + userId: 'user-1', + position: const LatLng(35.6762, 139.6503), + createdAt: DateTime.utc(2024, 1, 15), + isLocal: false, + memo: 'Existing memo', + ); + final updatedServerPin = cachedPin.copyWith(memo: null); + + when(() => mockStorage.getLocalPins()) + .thenAnswer((_) async => []); + when(() => mockNetworkChecker.isOnline) + .thenAnswer((_) async => true); + when(() => mockRepository.updatePin('server-pin-1', memo: null)) + .thenAnswer((_) async => updatedServerPin); + when(() => mockStorage.getCachedPins()) + .thenAnswer((_) async => [cachedPin]); + when(() => mockStorage.setCachedPins(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.getPendingMemoUpdates()) + .thenAnswer((_) async => {}); + + await syncService.updatePinMemo( + pinId: 'server-pin-1', + memo: null, + isAuthenticated: true, + ); + + verify(() => mockRepository.updatePin('server-pin-1', memo: null)).called(1); + final captured = + verify(() => mockStorage.setCachedPins(captureAny())).captured; + expect((captured.last as List).first.memo, isNull); + }); }); group('clearIfUserChanged', () {